rusty_stack/installers/common/
env_validation.rs1use crate::installers::common::utils::command_exists;
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const VALID_CHANNELS: &[&str] = &["legacy", "stable", "latest", "preview"];
25
26const DEFAULT_ROCM_VERSION: &str = "7.2.0";
28
29const DEFAULT_ROCM_CHANNEL: &str = "latest";
31
32const DEFAULT_GPU_ARCH: &str = "gfx1100";
34
35const DEFAULT_VENV_BASE_SUBDIR: &str = ".mlstack/venvs";
37
38#[derive(Debug, Clone)]
44pub struct EnvValidationResult {
45 pub file_existed: bool,
47 pub defaults_applied: bool,
49 pub vars: EnvVars,
51 pub warnings: Vec<String>,
53}
54
55#[derive(Debug, Clone)]
57pub struct EnvVars {
58 pub rocm_version: String,
60 pub rocm_channel: String,
62 pub gpu_arch: String,
64 pub venv_base: String,
66 pub python_bin: Option<String>,
68}
69
70impl Default for EnvVars {
71 fn default() -> Self {
72 Self {
73 rocm_version: DEFAULT_ROCM_VERSION.to_string(),
74 rocm_channel: DEFAULT_ROCM_CHANNEL.to_string(),
75 gpu_arch: DEFAULT_GPU_ARCH.to_string(),
76 venv_base: String::new(),
77 python_bin: None,
78 }
79 }
80}
81
82pub fn default_env_path() -> PathBuf {
88 dirs::home_dir()
89 .unwrap_or_else(|| PathBuf::from("/tmp"))
90 .join(".mlstack_env")
91}
92
93pub fn default_venv_base() -> String {
95 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
96 home.join(DEFAULT_VENV_BASE_SUBDIR)
97 .to_string_lossy()
98 .to_string()
99}
100
101pub fn validate_mlstack_env(script_name: Option<&str>) -> EnvValidationResult {
114 let env_path = default_env_path();
115 let mut warnings = Vec::new();
116 let mut defaults_applied = false;
117
118 if !env_path.exists() {
119 warnings.push(format!(
120 "Environment file not found: {} - using auto-detection",
121 env_path.display()
122 ));
123 let vars = auto_detect_vars(&mut defaults_applied);
124 return EnvValidationResult {
125 file_existed: false,
126 defaults_applied: true,
127 vars,
128 warnings,
129 };
130 }
131
132 let contents = match fs::read_to_string(&env_path) {
134 Ok(c) => c,
135 Err(e) => {
136 warnings.push(format!("Failed to read {}: {e}", env_path.display()));
137 let vars = auto_detect_vars(&mut defaults_applied);
138 return EnvValidationResult {
139 file_existed: true,
140 defaults_applied: true,
141 vars,
142 warnings,
143 };
144 }
145 };
146
147 let parsed = parse_env_file(&contents);
148
149 let mut vars = EnvVars {
151 rocm_version: String::new(),
152 rocm_channel: String::new(),
153 gpu_arch: String::new(),
154 venv_base: String::new(),
155 python_bin: None,
156 };
157 let _ = script_name; if let Some(v) = parsed.get("ROCM_VERSION") {
161 vars.rocm_version = v.clone();
162 } else {
163 vars.rocm_version = detect_rocm_version_or_default();
164 defaults_applied = true;
165 warnings.push("ROCM_VERSION not set, using auto-detection".to_string());
166 }
167
168 if let Some(v) = parsed.get("ROCM_CHANNEL") {
170 let lower = v.to_lowercase();
171 if VALID_CHANNELS.contains(&lower.as_str()) {
172 vars.rocm_channel = lower;
173 } else {
174 warnings.push(format!(
175 "Unknown ROCM_CHANNEL '{v}', defaulting to 'latest'"
176 ));
177 vars.rocm_channel = DEFAULT_ROCM_CHANNEL.to_string();
178 defaults_applied = true;
179 }
180 } else {
181 vars.rocm_channel = DEFAULT_ROCM_CHANNEL.to_string();
182 defaults_applied = true;
183 warnings.push("ROCM_CHANNEL not set, defaulting to 'latest'".to_string());
184 }
185
186 if let Some(v) = parsed.get("GPU_ARCH") {
188 vars.gpu_arch = v.clone();
189 } else {
190 vars.gpu_arch = detect_gpu_arch_or_default();
191 defaults_applied = true;
192 warnings.push("GPU_ARCH not set, using auto-detection".to_string());
193 }
194
195 vars.venv_base = parsed
197 .get("MLSTACK_VENV_BASE")
198 .cloned()
199 .unwrap_or_else(default_venv_base);
200
201 vars.python_bin = parsed.get("MLSTACK_PYTHON_BIN").cloned();
203
204 EnvValidationResult {
205 file_existed: true,
206 defaults_applied,
207 vars,
208 warnings,
209 }
210}
211
212pub fn require_mlstack_env(script_name: Option<&str>) -> EnvValidationResult {
217 validate_mlstack_env(script_name)
218}
219
220pub fn load_mlstack_env() -> bool {
229 let env_path = default_env_path();
230 if !env_path.exists() {
231 return false;
232 }
233
234 let contents = match fs::read_to_string(&env_path) {
235 Ok(c) => c,
236 Err(_) => return false,
237 };
238
239 for line in contents.lines() {
240 let trimmed = line.trim();
241 if trimmed.is_empty() || trimmed.starts_with('#') {
242 continue;
243 }
244
245 if let Some((key, raw_value)) = extract_export_assignment(trimmed) {
246 let value = strip_shell_suffixes(&raw_value);
247 let value = value
249 .strip_prefix('"')
250 .and_then(|v| v.strip_suffix('"'))
251 .unwrap_or(&value);
252 if !value.contains('$') {
254 std::env::set_var(&key, value);
255 }
256 }
257 }
258
259 true
260}
261
262fn parse_env_file(contents: &str) -> HashMap<String, String> {
275 let mut map = HashMap::new();
276
277 for line in contents.lines() {
278 let trimmed = line.trim();
279 if trimmed.is_empty() || trimmed.starts_with('#') {
280 continue;
281 }
282
283 if let Some(key_value) = extract_export_assignment(trimmed) {
286 let (key, value) = key_value;
287 let value = strip_shell_suffixes(&value);
288 let value = value
289 .strip_prefix('"')
290 .and_then(|v| v.strip_suffix('"'))
291 .unwrap_or(&value)
292 .to_string();
293 map.insert(key, value);
294 }
295 }
296 map
297}
298
299fn extract_export_assignment(line: &str) -> Option<(String, String)> {
306 let export_idx = line.rfind("export ")?;
308 let rest = &line[export_idx + 7..]; if let Some((key, value)) = rest.split_once('=') {
311 let key = key.trim().to_string();
312 if key.is_empty() || key.contains(' ') || key.contains('$') {
313 return None;
314 }
315 let value = value.trim().to_string();
316 Some((key, value))
317 } else {
318 None
319 }
320}
321
322fn strip_shell_suffixes(value: &str) -> String {
329 let mut value = value.to_string();
330
331 if let Some(idx) = value.find("; fi") {
333 value.truncate(idx);
334 }
335 if let Some(idx) = value.find(";& fi") {
336 value.truncate(idx);
337 }
338
339 if let Some(idx) = value.find("&& ") {
341 value.truncate(idx);
342 }
343
344 if let Some(idx) = value.find("; then") {
346 value.truncate(idx);
347 }
348
349 value.trim().to_string()
350}
351
352fn auto_detect_vars(defaults_applied: &mut bool) -> EnvVars {
354 *defaults_applied = true;
355 EnvVars {
356 rocm_version: detect_rocm_version_or_default(),
357 rocm_channel: DEFAULT_ROCM_CHANNEL.to_string(),
358 gpu_arch: detect_gpu_arch_or_default(),
359 venv_base: default_venv_base(),
360 python_bin: None,
361 }
362}
363
364fn detect_rocm_version_or_default() -> String {
366 #[cfg(unix)]
368 {
369 let version = crate::platform::linux::get_rocm_version();
370 if !version.is_empty() {
371 return version;
372 }
373 }
374
375 let version_file = Path::new("/opt/rocm/.info/version");
377 if let Ok(contents) = fs::read_to_string(version_file) {
378 let v = contents.trim().to_string();
379 if !v.is_empty() {
380 return v;
381 }
382 }
383
384 DEFAULT_ROCM_VERSION.to_string()
385}
386
387fn detect_gpu_arch_or_default() -> String {
389 let rocminfo_path = crate::installers::common::utils::resolve_rocminfo_path();
391 if command_exists(&rocminfo_path) || command_exists("rocminfo") {
392 if let Ok(output) = std::process::Command::new(&rocminfo_path).output() {
393 if output.status.success() {
394 let stdout = String::from_utf8_lossy(&output.stdout);
395 for line in stdout.lines() {
396 let trimmed = line.trim();
397 if trimmed.starts_with("Name:") || trimmed.contains("gfx") {
398 if let Some(idx) = trimmed.find("gfx") {
400 let rest = &trimmed[idx..];
401 let gfx: String = rest
402 .chars()
403 .take_while(|c| c.is_ascii_alphanumeric())
404 .collect();
405 if gfx.starts_with("gfx") && gfx.len() >= 5 {
406 return gfx;
407 }
408 }
409 }
410 }
411 }
412 }
413 }
414
415 DEFAULT_GPU_ARCH.to_string()
416}
417
418#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
429 fn test_parse_env_file_basic() {
430 let contents = r#"
431# Comment
432export ROCM_VERSION=7.2.0
433export ROCM_CHANNEL=latest
434export GPU_ARCH=gfx1100
435"#;
436 let map = parse_env_file(contents);
437 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.0");
438 assert_eq!(map.get("ROCM_CHANNEL").unwrap(), "latest");
439 assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
440 }
441
442 #[test]
443 fn test_parse_env_file_quoted_values() {
444 let contents = r#"
445export PATH="/opt/rocm/bin:$PATH"
446export ROCM_PATH=/opt/rocm
447"#;
448 let map = parse_env_file(contents);
449 assert_eq!(map.get("PATH").unwrap(), "/opt/rocm/bin:$PATH");
450 assert_eq!(map.get("ROCM_PATH").unwrap(), "/opt/rocm");
451 }
452
453 #[test]
454 fn test_parse_env_file_empty() {
455 let map = parse_env_file("");
456 assert!(map.is_empty());
457 }
458
459 #[test]
460 fn test_parse_env_file_comments_only() {
461 let contents = "# just comments\n# another comment\n";
462 let map = parse_env_file(contents);
463 assert!(map.is_empty());
464 }
465
466 #[test]
469 fn test_validate_missing_env_file() {
470 let _temp_dir = tempfile::tempdir().unwrap();
472 let result = validate_mlstack_env(Some("test_script"));
474 assert!(
476 result.vars.rocm_channel == "latest"
477 || result.vars.rocm_channel == "stable"
478 || result.vars.rocm_channel == "legacy"
479 || result.vars.rocm_channel == "preview"
480 );
481 }
482
483 #[test]
484 fn test_validate_always_returns_defaults() {
485 let result = validate_mlstack_env(None);
486 assert!(!result.vars.rocm_version.is_empty());
488 assert!(!result.vars.rocm_channel.is_empty());
489 assert!(!result.vars.gpu_arch.is_empty());
490 }
491
492 #[test]
493 fn test_require_mlstack_env_always_succeeds() {
494 let result = require_mlstack_env(None);
495 assert!(!result.vars.rocm_version.is_empty());
497 }
498
499 #[test]
500 fn test_valid_channels() {
501 for ch in VALID_CHANNELS {
502 assert!(VALID_CHANNELS.contains(ch));
503 }
504 }
505
506 #[test]
509 fn test_env_vars_default() {
510 let vars = EnvVars::default();
511 assert_eq!(vars.rocm_version, "7.2.0");
512 assert_eq!(vars.rocm_channel, "latest");
513 assert_eq!(vars.gpu_arch, "gfx1100");
514 assert!(vars.python_bin.is_none());
515 }
516
517 #[test]
520 fn test_default_venv_base() {
521 let base = default_venv_base();
522 assert!(base.contains(".mlstack/venvs"));
523 }
524
525 #[test]
528 fn test_load_mlstack_env_nonexistent() {
529 let _ = load_mlstack_env();
532 }
533
534 #[test]
537 fn test_detect_rocm_version_no_panic() {
538 let version = detect_rocm_version_or_default();
539 assert!(!version.is_empty());
540 }
541
542 #[test]
545 fn test_detect_gpu_arch_no_panic() {
546 let arch = detect_gpu_arch_or_default();
547 assert!(!arch.is_empty());
548 assert!(arch.starts_with("gfx"));
549 }
550
551 #[test]
554 fn test_validate_with_valid_channel() {
555 let vars = EnvVars::default();
557 assert!(VALID_CHANNELS.contains(&vars.rocm_channel.as_str()));
558 }
559
560 #[test]
561 fn test_gpu_arch_format() {
562 let arch = detect_gpu_arch_or_default();
563 assert!(arch.starts_with("gfx"));
565 assert!(arch.len() >= 5); }
567
568 #[test]
571 fn test_parse_env_file_if_guard_single_line() {
572 let contents =
575 r#"if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi"#;
576 let map = parse_env_file(contents);
577 let value = map.get("HIP_VISIBLE_DEVICES").unwrap();
578 assert_eq!(value, "0,1,2", "Should strip '; fi' suffix, got: {value}");
579 assert!(
580 !value.contains("; fi"),
581 "Value must not contain shell suffixes"
582 );
583 }
584
585 #[test]
586 fn test_parse_env_file_rocm_version_if_guard() {
587 let contents = r#"if [ -z "${ROCM_VERSION:-}" ]; then export ROCM_VERSION=7.2.2; fi"#;
588 let map = parse_env_file(contents);
589 let value = map.get("ROCM_VERSION").unwrap();
590 assert_eq!(value, "7.2.2", "Should strip '; fi' suffix, got: {value}");
591 }
592
593 #[test]
594 fn test_parse_env_file_rocm_home_if_guard() {
595 let contents = r#"if [ -z "${ROCM_HOME:-}" ]; then export ROCM_HOME=/opt/rocm; fi"#;
596 let map = parse_env_file(contents);
597 let value = map.get("ROCM_HOME").unwrap();
598 assert_eq!(
599 value, "/opt/rocm",
600 "Should strip '; fi' suffix, got: {value}"
601 );
602 }
603
604 #[test]
605 fn test_parse_env_file_mixed_formats() {
606 let contents = r#"
608# ML Stack Environment File
609if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi
610if [ -z "${ROCM_VERSION:-}" ]; then export ROCM_VERSION=7.2.2; fi
611export GPU_ARCH=gfx1100
612export HSA_OVERRIDE_GFX_VERSION=11.0.0
613if [ -z "${HSA_ENABLE_SDMA:-}" ]; then export HSA_ENABLE_SDMA=0; fi
614"#;
615 let map = parse_env_file(contents);
616
617 assert_eq!(map.get("HIP_VISIBLE_DEVICES").unwrap(), "0,1,2");
618 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.2");
619 assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
620 assert_eq!(map.get("HSA_OVERRIDE_GFX_VERSION").unwrap(), "11.0.0");
621 assert_eq!(map.get("HSA_ENABLE_SDMA").unwrap(), "0");
622
623 for (key, value) in &map {
625 assert!(
626 !value.contains("; fi"),
627 "{key}: Value contains '; fi': {value}"
628 );
629 assert!(!value.contains("&&"), "{key}: Value contains '&&': {value}");
630 }
631 }
632
633 #[test]
634 fn test_parse_env_file_multiline_if_guard() {
635 let contents = r#"if [ -z "${HSA_TOOLS_LIB:-}" ]; then
637 if [ -f "/opt/rocm/lib/rocprofiler-sdk/librocprofiler-sdk-tool.so" ]; then
638 export HSA_TOOLS_LIB="/opt/rocm/lib/rocprofiler-sdk/librocprofiler-sdk-tool.so"
639 else
640 export HSA_TOOLS_LIB=0
641 fi
642fi"#;
643 let map = parse_env_file(contents);
644 let value = map.get("HSA_TOOLS_LIB");
646 assert!(
647 value.is_some(),
648 "Should find HSA_TOOLS_LIB in multiline if-guard"
649 );
650 let val = value.unwrap();
651 assert!(!val.contains("fi"), "Value should not contain 'fi': {val}");
652 }
653
654 #[test]
655 fn test_parse_env_file_no_shell_suffixes_in_values() {
656 let contents = r#"
657if [ -z "${CUDA_VISIBLE_DEVICES:-}" ]; then export CUDA_VISIBLE_DEVICES=0,1,2; fi
658if [ -z "${PYTORCH_ROCM_DEVICE:-}" ]; then export PYTORCH_ROCM_DEVICE=0,1,2; fi
659if [ -z "${ROCM_CHANNEL:-}" ]; then export ROCM_CHANNEL=latest; fi
660"#;
661 let map = parse_env_file(contents);
662 for (key, value) in &map {
663 assert!(!value.contains(';'), "{key}: Value contains ';': {value}");
664 assert!(!value.contains("fi"), "{key}: Value contains 'fi': {value}");
665 }
666 }
667
668 #[test]
669 fn test_parse_env_file_direct_export_unmodified() {
670 let contents = r#"
672export ROCM_VERSION=7.2.2
673export GPU_ARCH=gfx1100
674export ROCM_HOME=/opt/rocm
675"#;
676 let map = parse_env_file(contents);
677 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.2");
678 assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
679 assert_eq!(map.get("ROCM_HOME").unwrap(), "/opt/rocm");
680 }
681
682 #[test]
683 fn test_parse_env_file_permanent_env_format() {
684 let contents = r#"# Permanent ROCm Environment Setup (Generated by rusty-stack)
686export ROCM_VERSION=7.2.0
687export ROCM_CHANNEL=latest
688export GPU_ARCH=gfx1100
689export PYTORCH_ROCM_ARCH=gfx1100
690export ROCM_HOME=/opt/rocm
691export ROCM_PATH=/opt/rocm
692export HIP_VISIBLE_DEVICES=0
693export CUDA_VISIBLE_DEVICES=0
694export MLSTACK_PYTHON_BIN=python3
695export HSA_OVERRIDE_GFX_VERSION=11.0.0
696"#;
697 let map = parse_env_file(contents);
698 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.0");
699 assert_eq!(map.get("HIP_VISIBLE_DEVICES").unwrap(), "0");
700 assert_eq!(map.get("HSA_OVERRIDE_GFX_VERSION").unwrap(), "11.0.0");
701 }
702}