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 let arch = v.trim();
189 if arch == "gfx000" {
190 if let Some(detected) = crate::gpu::detect_discrete_amd_gpus()
192 .first()
193 .and_then(|g| g.gfx_arch.clone())
194 {
195 vars.gpu_arch = detected;
196 warnings
197 .push("GPU_ARCH was gfx000 (invalid), substituted detected arch".to_string());
198 } else {
199 warnings.push(
200 "GPU_ARCH is gfx000 and no GPU detected — env file may be invalid".to_string(),
201 );
202 vars.gpu_arch = "gfx1100".to_string();
203 }
204 } else {
205 vars.gpu_arch = v.clone();
206 }
207 } else {
208 vars.gpu_arch = detect_gpu_arch_or_default();
209 defaults_applied = true;
210 warnings.push("GPU_ARCH not set, using auto-detection".to_string());
211 }
212
213 vars.venv_base = parsed
215 .get("MLSTACK_VENV_BASE")
216 .cloned()
217 .unwrap_or_else(default_venv_base);
218
219 vars.python_bin = parsed.get("MLSTACK_PYTHON_BIN").cloned();
221
222 EnvValidationResult {
223 file_existed: true,
224 defaults_applied,
225 vars,
226 warnings,
227 }
228}
229
230pub fn require_mlstack_env(script_name: Option<&str>) -> EnvValidationResult {
235 validate_mlstack_env(script_name)
236}
237
238pub fn load_mlstack_env() -> bool {
247 let env_path = default_env_path();
248 if !env_path.exists() {
249 return false;
250 }
251
252 let contents = match fs::read_to_string(&env_path) {
253 Ok(c) => c,
254 Err(_) => return false,
255 };
256
257 for line in contents.lines() {
258 let trimmed = line.trim();
259 if trimmed.is_empty() || trimmed.starts_with('#') {
260 continue;
261 }
262
263 if let Some((key, raw_value)) = extract_export_assignment(trimmed) {
264 let value = strip_shell_suffixes(&raw_value);
265 let value = value
267 .strip_prefix('"')
268 .and_then(|v| v.strip_suffix('"'))
269 .unwrap_or(&value);
270 if !value.contains('$') {
272 std::env::set_var(&key, value);
273 }
274 }
275 }
276
277 true
278}
279
280fn parse_env_file(contents: &str) -> HashMap<String, String> {
293 let mut map = HashMap::new();
294
295 for line in contents.lines() {
296 let trimmed = line.trim();
297 if trimmed.is_empty() || trimmed.starts_with('#') {
298 continue;
299 }
300
301 if let Some(key_value) = extract_export_assignment(trimmed) {
304 let (key, value) = key_value;
305 let value = strip_shell_suffixes(&value);
306 let value = value
307 .strip_prefix('"')
308 .and_then(|v| v.strip_suffix('"'))
309 .unwrap_or(&value)
310 .to_string();
311 map.insert(key, value);
312 }
313 }
314 map
315}
316
317fn extract_export_assignment(line: &str) -> Option<(String, String)> {
324 let export_idx = line.rfind("export ")?;
326 let rest = &line[export_idx + 7..]; if let Some((key, value)) = rest.split_once('=') {
329 let key = key.trim().to_string();
330 if key.is_empty() || key.contains(' ') || key.contains('$') {
331 return None;
332 }
333 let value = value.trim().to_string();
334 Some((key, value))
335 } else {
336 None
337 }
338}
339
340fn strip_shell_suffixes(value: &str) -> String {
347 let mut value = value.to_string();
348
349 if let Some(idx) = value.find("; fi") {
351 value.truncate(idx);
352 }
353 if let Some(idx) = value.find(";& fi") {
354 value.truncate(idx);
355 }
356
357 if let Some(idx) = value.find("&& ") {
359 value.truncate(idx);
360 }
361
362 if let Some(idx) = value.find("; then") {
364 value.truncate(idx);
365 }
366
367 value.trim().to_string()
368}
369
370fn auto_detect_vars(defaults_applied: &mut bool) -> EnvVars {
372 *defaults_applied = true;
373 EnvVars {
374 rocm_version: detect_rocm_version_or_default(),
375 rocm_channel: DEFAULT_ROCM_CHANNEL.to_string(),
376 gpu_arch: detect_gpu_arch_or_default(),
377 venv_base: default_venv_base(),
378 python_bin: None,
379 }
380}
381
382fn detect_rocm_version_or_default() -> String {
384 #[cfg(unix)]
386 {
387 let version = crate::platform::linux::get_rocm_version();
388 if !version.is_empty() {
389 return version;
390 }
391 }
392
393 let version_file = Path::new("/opt/rocm/.info/version");
395 if let Ok(contents) = fs::read_to_string(version_file) {
396 let v = contents.trim().to_string();
397 if !v.is_empty() {
398 return v;
399 }
400 }
401
402 DEFAULT_ROCM_VERSION.to_string()
403}
404
405fn detect_gpu_arch_or_default() -> String {
407 let rocminfo_path = crate::installers::common::utils::resolve_rocminfo_path();
409 if command_exists(&rocminfo_path) || command_exists("rocminfo") {
410 if let Ok(output) = std::process::Command::new(&rocminfo_path).output() {
411 if output.status.success() {
412 let stdout = String::from_utf8_lossy(&output.stdout);
413 for line in stdout.lines() {
414 let trimmed = line.trim();
415 if trimmed.starts_with("Name:") || trimmed.contains("gfx") {
416 if let Some(idx) = trimmed.find("gfx") {
418 let rest = &trimmed[idx..];
419 let gfx: String = rest
420 .chars()
421 .take_while(|c| c.is_ascii_alphanumeric())
422 .collect();
423 if gfx.starts_with("gfx") && gfx.len() >= 5 {
424 return gfx;
425 }
426 }
427 }
428 }
429 }
430 }
431 }
432
433 DEFAULT_GPU_ARCH.to_string()
434}
435
436#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
447 fn test_parse_env_file_basic() {
448 let contents = r#"
449# Comment
450export ROCM_VERSION=7.2.0
451export ROCM_CHANNEL=latest
452export GPU_ARCH=gfx1100
453"#;
454 let map = parse_env_file(contents);
455 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.0");
456 assert_eq!(map.get("ROCM_CHANNEL").unwrap(), "latest");
457 assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
458 }
459
460 #[test]
461 fn test_parse_env_file_quoted_values() {
462 let contents = r#"
463export PATH="/opt/rocm/bin:$PATH"
464export ROCM_PATH=/opt/rocm
465"#;
466 let map = parse_env_file(contents);
467 assert_eq!(map.get("PATH").unwrap(), "/opt/rocm/bin:$PATH");
468 assert_eq!(map.get("ROCM_PATH").unwrap(), "/opt/rocm");
469 }
470
471 #[test]
472 fn test_parse_env_file_empty() {
473 let map = parse_env_file("");
474 assert!(map.is_empty());
475 }
476
477 #[test]
478 fn test_parse_env_file_comments_only() {
479 let contents = "# just comments\n# another comment\n";
480 let map = parse_env_file(contents);
481 assert!(map.is_empty());
482 }
483
484 #[test]
487 fn test_validate_missing_env_file() {
488 let _temp_dir = tempfile::tempdir().unwrap();
490 let result = validate_mlstack_env(Some("test_script"));
492 assert!(
494 result.vars.rocm_channel == "latest"
495 || result.vars.rocm_channel == "stable"
496 || result.vars.rocm_channel == "legacy"
497 || result.vars.rocm_channel == "preview"
498 );
499 }
500
501 #[test]
502 fn test_validate_always_returns_defaults() {
503 let result = validate_mlstack_env(None);
504 assert!(!result.vars.rocm_version.is_empty());
506 assert!(!result.vars.rocm_channel.is_empty());
507 assert!(!result.vars.gpu_arch.is_empty());
508 }
509
510 #[test]
511 fn test_require_mlstack_env_always_succeeds() {
512 let result = require_mlstack_env(None);
513 assert!(!result.vars.rocm_version.is_empty());
515 }
516
517 #[test]
518 fn test_valid_channels() {
519 for ch in VALID_CHANNELS {
520 assert!(VALID_CHANNELS.contains(ch));
521 }
522 }
523
524 #[test]
527 fn test_env_vars_default() {
528 let vars = EnvVars::default();
529 assert_eq!(vars.rocm_version, "7.2.0");
530 assert_eq!(vars.rocm_channel, "latest");
531 assert_eq!(vars.gpu_arch, "gfx1100");
532 assert!(vars.python_bin.is_none());
533 }
534
535 #[test]
538 fn test_default_venv_base() {
539 let base = default_venv_base();
540 assert!(base.contains(".mlstack/venvs"));
541 }
542
543 #[test]
546 fn test_load_mlstack_env_nonexistent() {
547 let _ = load_mlstack_env();
550 }
551
552 #[test]
555 fn test_detect_rocm_version_no_panic() {
556 let version = detect_rocm_version_or_default();
557 assert!(!version.is_empty());
558 }
559
560 #[test]
563 fn test_detect_gpu_arch_no_panic() {
564 let arch = detect_gpu_arch_or_default();
565 assert!(!arch.is_empty());
566 assert!(arch.starts_with("gfx"));
567 }
568
569 #[test]
572 fn test_validate_with_valid_channel() {
573 let vars = EnvVars::default();
575 assert!(VALID_CHANNELS.contains(&vars.rocm_channel.as_str()));
576 }
577
578 #[test]
579 fn test_gpu_arch_format() {
580 let arch = detect_gpu_arch_or_default();
581 assert!(arch.starts_with("gfx"));
583 assert!(arch.len() >= 5); }
585
586 #[test]
589 fn test_parse_env_file_if_guard_single_line() {
590 let contents =
593 r#"if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi"#;
594 let map = parse_env_file(contents);
595 let value = map.get("HIP_VISIBLE_DEVICES").unwrap();
596 assert_eq!(value, "0,1,2", "Should strip '; fi' suffix, got: {value}");
597 assert!(
598 !value.contains("; fi"),
599 "Value must not contain shell suffixes"
600 );
601 }
602
603 #[test]
604 fn test_parse_env_file_rocm_version_if_guard() {
605 let contents = r#"if [ -z "${ROCM_VERSION:-}" ]; then export ROCM_VERSION=7.2.2; fi"#;
606 let map = parse_env_file(contents);
607 let value = map.get("ROCM_VERSION").unwrap();
608 assert_eq!(value, "7.2.2", "Should strip '; fi' suffix, got: {value}");
609 }
610
611 #[test]
612 fn test_parse_env_file_rocm_home_if_guard() {
613 let contents = r#"if [ -z "${ROCM_HOME:-}" ]; then export ROCM_HOME=/opt/rocm; fi"#;
614 let map = parse_env_file(contents);
615 let value = map.get("ROCM_HOME").unwrap();
616 assert_eq!(
617 value, "/opt/rocm",
618 "Should strip '; fi' suffix, got: {value}"
619 );
620 }
621
622 #[test]
623 fn test_parse_env_file_mixed_formats() {
624 let contents = r#"
626# ML Stack Environment File
627if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi
628if [ -z "${ROCM_VERSION:-}" ]; then export ROCM_VERSION=7.2.2; fi
629export GPU_ARCH=gfx1100
630export HSA_OVERRIDE_GFX_VERSION=11.0.0
631if [ -z "${HSA_ENABLE_SDMA:-}" ]; then export HSA_ENABLE_SDMA=0; fi
632"#;
633 let map = parse_env_file(contents);
634
635 assert_eq!(map.get("HIP_VISIBLE_DEVICES").unwrap(), "0,1,2");
636 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.2");
637 assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
638 assert_eq!(map.get("HSA_OVERRIDE_GFX_VERSION").unwrap(), "11.0.0");
639 assert_eq!(map.get("HSA_ENABLE_SDMA").unwrap(), "0");
640
641 for (key, value) in &map {
643 assert!(
644 !value.contains("; fi"),
645 "{key}: Value contains '; fi': {value}"
646 );
647 assert!(!value.contains("&&"), "{key}: Value contains '&&': {value}");
648 }
649 }
650
651 #[test]
652 fn test_parse_env_file_multiline_if_guard() {
653 let contents = r#"if [ -z "${HSA_TOOLS_LIB:-}" ]; then
655 if [ -f "/opt/rocm/lib/rocprofiler-sdk/librocprofiler-sdk-tool.so" ]; then
656 export HSA_TOOLS_LIB="/opt/rocm/lib/rocprofiler-sdk/librocprofiler-sdk-tool.so"
657 else
658 export HSA_TOOLS_LIB=0
659 fi
660fi"#;
661 let map = parse_env_file(contents);
662 let value = map.get("HSA_TOOLS_LIB");
664 assert!(
665 value.is_some(),
666 "Should find HSA_TOOLS_LIB in multiline if-guard"
667 );
668 let val = value.unwrap();
669 assert!(!val.contains("fi"), "Value should not contain 'fi': {val}");
670 }
671
672 #[test]
673 fn test_parse_env_file_no_shell_suffixes_in_values() {
674 let contents = r#"
675if [ -z "${CUDA_VISIBLE_DEVICES:-}" ]; then export CUDA_VISIBLE_DEVICES=0,1,2; fi
676if [ -z "${PYTORCH_ROCM_DEVICE:-}" ]; then export PYTORCH_ROCM_DEVICE=0,1,2; fi
677if [ -z "${ROCM_CHANNEL:-}" ]; then export ROCM_CHANNEL=latest; fi
678"#;
679 let map = parse_env_file(contents);
680 for (key, value) in &map {
681 assert!(!value.contains(';'), "{key}: Value contains ';': {value}");
682 assert!(!value.contains("fi"), "{key}: Value contains 'fi': {value}");
683 }
684 }
685
686 #[test]
687 fn test_parse_env_file_direct_export_unmodified() {
688 let contents = r#"
690export ROCM_VERSION=7.2.2
691export GPU_ARCH=gfx1100
692export ROCM_HOME=/opt/rocm
693"#;
694 let map = parse_env_file(contents);
695 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.2");
696 assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
697 assert_eq!(map.get("ROCM_HOME").unwrap(), "/opt/rocm");
698 }
699
700 #[test]
701 fn test_parse_env_file_permanent_env_format() {
702 let contents = r#"# Permanent ROCm Environment Setup (Generated by rusty-stack)
704export ROCM_VERSION=7.2.0
705export ROCM_CHANNEL=latest
706export GPU_ARCH=gfx1100
707export PYTORCH_ROCM_ARCH=gfx1100
708export ROCM_HOME=/opt/rocm
709export ROCM_PATH=/opt/rocm
710export HIP_VISIBLE_DEVICES=0
711export CUDA_VISIBLE_DEVICES=0
712export MLSTACK_PYTHON_BIN=python3
713export HSA_OVERRIDE_GFX_VERSION=11.0.0
714"#;
715 let map = parse_env_file(contents);
716 assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.0");
717 assert_eq!(map.get("HIP_VISIBLE_DEVICES").unwrap(), "0");
718 assert_eq!(map.get("HSA_OVERRIDE_GFX_VERSION").unwrap(), "11.0.0");
719 }
720}