rusty_stack/orchestrator/
migration.rs1use crate::core::manifest::{self, Manifest, ManifestFetcher, ManifestSource, ResolvedManifest};
18use crate::orchestrator::planner::{CompatibilityContext, PlannerOptions, UpdatePlanner};
19use crate::platform::registry;
20
21use std::collections::{HashMap, HashSet};
22use std::path::Path;
23
24pub fn migrated_get_version(component_id: &str) -> String {
43 registry::get_version(component_id)
44}
45
46pub fn migrated_get_version_with_home(component_id: &str, home: &Path) -> String {
51 registry::get_version_with_home(component_id, home)
52}
53
54pub fn migrated_get_versions_batch(component_ids: &[&str]) -> Vec<registry::VersionInfo> {
64 registry::get_versions_batch(component_ids)
65}
66
67pub fn migrated_build_selection_plan(
84 manifest: &Manifest,
85 context: &CompatibilityContext,
86 options: &PlannerOptions,
87) -> Result<Vec<PlannerItem>, crate::orchestrator::planner::PlannerError> {
88 let planner = UpdatePlanner::new();
89 planner.build_plan(manifest, context, options)
90}
91
92pub use crate::orchestrator::planner::PlannerItem;
94
95pub fn build_plan_from_system(
105 manifest: &Manifest,
106 rocm_channel: &str,
107 include_experimental: bool,
108) -> Result<Vec<PlannerItem>, crate::orchestrator::planner::PlannerError> {
109 let installed_ids = registry::detect_all_installed();
111 let mut installed_versions = HashMap::new();
112 let mut installed_set = HashSet::new();
113
114 for id in &installed_ids {
115 let v = registry::get_version(id);
116 installed_versions.insert(id.clone(), v);
117 installed_set.insert(id.clone());
118 }
119
120 let rocm_version = installed_versions.get("rocm").cloned().unwrap_or_default();
122
123 let context = CompatibilityContext {
125 rocm_version,
126 rocm_channel: rocm_channel.to_string(),
127 available_executors: HashSet::from([
128 crate::core::types::ExecutorKind::LegacyScript,
129 crate::core::types::ExecutorKind::Rust,
130 ]),
131 installed_components: installed_set,
132 installed_versions,
133 gpu_architecture: String::new(),
134 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
135 };
136
137 let options = PlannerOptions {
138 include_experimental,
139 ..PlannerOptions::default()
140 };
141
142 migrated_build_selection_plan(manifest, &context, &options)
143}
144
145#[derive(Debug, Clone)]
151pub struct FreshnessResult {
152 pub is_fresh: bool,
154 pub source: ManifestSource,
156 pub warning: Option<String>,
158 pub refetch_triggered: bool,
160}
161
162pub fn check_manifest_freshness(fetcher: &dyn ManifestFetcher) -> FreshnessResult {
180 if let Some(remote) = fetcher.fetch_remote() {
182 if remote.verify_trust().is_ok() {
183 return FreshnessResult {
184 is_fresh: true,
185 source: ManifestSource::FreshRemote,
186 warning: None,
187 refetch_triggered: false,
188 };
189 }
190 }
193
194 if let Some(_cached) = fetcher.load_cached() {
196 return FreshnessResult {
197 is_fresh: false,
198 source: ManifestSource::CachedRemote,
199 warning: Some(
200 "Using cached manifest (remote unavailable or trust check failed)".to_string(),
201 ),
202 refetch_triggered: true,
203 };
204 }
205
206 FreshnessResult {
208 is_fresh: false,
209 source: ManifestSource::Baseline,
210 warning: Some("Using bundled baseline manifest (no remote, no cache)".to_string()),
211 refetch_triggered: true,
212 }
213}
214
215pub fn resolve_with_freshness(
220 fetcher: &dyn ManifestFetcher,
221) -> (ResolvedManifest, FreshnessResult) {
222 let freshness = check_manifest_freshness(fetcher);
223 let resolved = manifest::resolve_manifest(fetcher);
224 (resolved, freshness)
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ComponentInstallStatus {
234 Installed,
236 NotInstalled,
238 NeedsUpdate,
240}
241
242#[derive(Debug, Clone)]
244pub struct DetectionResult {
245 pub component_id: String,
247 pub status: ComponentInstallStatus,
249 pub version: String,
251 pub detection_method: String,
253}
254
255pub fn migrated_detect_all() -> Vec<DetectionResult> {
269 let home = crate::platform::environment::resolve_user_home();
270 migrated_detect_all_with_home(&home)
271}
272
273pub fn migrated_detect_all_with_home(home: &Path) -> Vec<DetectionResult> {
275 let installed_ids = registry::detect_all_installed_with_home(home);
276 let mut results = Vec::new();
277
278 for info in registry::known_components() {
279 let version = registry::get_version_with_home(&info.id, home);
280 let is_installed = installed_ids.contains(&info.id);
281
282 let status = if is_installed {
283 ComponentInstallStatus::Installed
284 } else {
285 check_partial_installation(&info.id, home)
286 };
287
288 results.push(DetectionResult {
289 component_id: info.id.clone(),
290 status,
291 version,
292 detection_method: format!("{:?}", info.detection_method),
293 });
294 }
295
296 results
297}
298
299pub fn detect_component_status(component_id: &str) -> DetectionResult {
306 let home = crate::platform::environment::resolve_user_home();
307 detect_component_status_with_home(component_id, &home)
308}
309
310pub fn detect_component_status_with_home(component_id: &str, home: &Path) -> DetectionResult {
312 let info = registry::get_component(component_id);
313 let is_installed = registry::is_component_installed_with_home(component_id, home);
314 let version = registry::get_version_with_home(component_id, home);
315
316 let status = if is_installed {
317 ComponentInstallStatus::Installed
318 } else {
319 check_partial_installation(component_id, home)
320 };
321
322 DetectionResult {
323 component_id: component_id.to_string(),
324 status,
325 version,
326 detection_method: info
327 .map(|i| format!("{:?}", i.detection_method))
328 .unwrap_or_else(|| "Unknown".to_string()),
329 }
330}
331
332fn check_partial_installation(component_id: &str, home: &Path) -> ComponentInstallStatus {
342 match component_id {
343 "rocm" => {
344 let version_file = Path::new("/opt/rocm/.info/version").exists();
346 let rocminfo_works = std::process::Command::new("rocminfo")
347 .arg("--version")
348 .output()
349 .map(|o| o.status.success())
350 .unwrap_or(false);
351
352 if version_file && !rocminfo_works {
353 ComponentInstallStatus::NeedsUpdate
354 } else {
355 ComponentInstallStatus::NotInstalled
356 }
357 }
358 "comfyui" | "vllm-studio" | "textgen" => {
359 let clone_dir = match component_id {
361 "comfyui" => "ComfyUI",
362 "vllm-studio" => "vllm-studio",
363 "textgen" => "text-generation-webui",
364 _ => return ComponentInstallStatus::NotInstalled,
365 };
366 let dir = home.join(clone_dir);
367 if dir.exists() && !dir.join(".git").exists() {
368 ComponentInstallStatus::NeedsUpdate
369 } else {
370 ComponentInstallStatus::NotInstalled
371 }
372 }
373 "permanent-env" => {
374 let env_file = home.join(".mlstack_env");
376 if env_file.exists() {
377 let content = std::fs::read_to_string(&env_file).unwrap_or_default();
378 if !content.contains("MLSTACK_PYTHON_BIN") {
379 ComponentInstallStatus::NeedsUpdate
380 } else {
381 ComponentInstallStatus::NotInstalled
382 }
383 } else {
384 ComponentInstallStatus::NotInstalled
385 }
386 }
387 _ => ComponentInstallStatus::NotInstalled,
388 }
389}
390
391#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
404 fn test_migrated_get_version_all_components_no_panic() {
405 let ids = [
406 "rocm",
407 "pytorch",
408 "triton",
409 "deepspeed",
410 "vllm",
411 "aiter",
412 "onnx",
413 "bitsandbytes",
414 "migraphx",
415 "flash-attn",
416 "mpi4py",
417 "wandb",
418 "comfyui",
419 "vllm-studio",
420 "textgen",
421 "rocm-smi",
422 "permanent-env",
423 ];
424
425 for id in &ids {
426 let v = migrated_get_version(id);
427 assert!(!v.is_empty(), "version for '{id}' must not be empty");
428 }
429 }
430
431 #[test]
436 fn test_migrated_get_version_unknown_returns_unknown() {
437 assert_eq!(migrated_get_version("nonexistent"), "unknown");
438 assert_eq!(migrated_get_version("fake-component"), "unknown");
439 assert_eq!(migrated_get_version(""), "unknown");
440 }
441
442 #[test]
447 fn test_migrated_detect_all_returns_valid_results() {
448 let results = migrated_detect_all();
449
450 for result in &results {
452 assert!(
453 registry::is_known_component(&result.component_id),
454 "result component must be known: {}",
455 result.component_id
456 );
457 }
458 }
459
460 #[test]
465 fn test_partial_installation_detection_with_temp_dir() {
466 let temp_dir = tempfile::tempdir().unwrap();
467 let home = temp_dir.path();
468
469 let comfyui_dir = home.join("ComfyUI");
471 std::fs::create_dir_all(&comfyui_dir).unwrap();
472
473 let result = detect_component_status_with_home("comfyui", home);
474 assert_eq!(
475 result.status,
476 ComponentInstallStatus::NeedsUpdate,
477 "ComfyUI dir without .git must be NeedsUpdate"
478 );
479
480 std::fs::create_dir_all(comfyui_dir.join(".git")).unwrap();
482 let result = detect_component_status_with_home("comfyui", home);
483 assert_eq!(
484 result.status,
485 ComponentInstallStatus::Installed,
486 "ComfyUI with .git must be Installed"
487 );
488 }
489
490 #[test]
491 fn test_partial_installation_permanent_env() {
492 let temp_dir = tempfile::tempdir().unwrap();
493 let home = temp_dir.path();
494
495 let result = detect_component_status_with_home("permanent-env", home);
497 assert_eq!(result.status, ComponentInstallStatus::NotInstalled);
498
499 let env_file = home.join(".mlstack_env");
501 std::fs::write(&env_file, "# empty").unwrap();
502 let result = detect_component_status_with_home("permanent-env", home);
503 assert_eq!(
504 result.status,
505 ComponentInstallStatus::NeedsUpdate,
506 "env file without marker must be NeedsUpdate"
507 );
508
509 std::fs::write(&env_file, "export MLSTACK_PYTHON_BIN=/usr/bin/python3").unwrap();
511 let result = detect_component_status_with_home("permanent-env", home);
512 assert_eq!(
513 result.status,
514 ComponentInstallStatus::Installed,
515 "env file with marker must be Installed"
516 );
517 }
518
519 struct MockFetcher {
524 remote: Option<Manifest>,
525 cached: Option<Manifest>,
526 }
527
528 impl ManifestFetcher for MockFetcher {
529 fn fetch_remote(&self) -> Option<Manifest> {
530 self.remote.clone()
531 }
532 fn load_cached(&self) -> Option<Manifest> {
533 self.cached.clone()
534 }
535 }
536
537 fn make_signed_manifest() -> Manifest {
538 let mut m = Manifest {
539 schema_version: manifest::CURRENT_SCHEMA_VERSION,
540 sequence: 1,
541 generated_at: chrono::Utc::now().to_rfc3339(),
542 expires_at: None,
543 min_runtime_version: String::new(),
544 components: vec![],
545 signature: None,
546 };
547 m.signature = Some(m.compute_signature());
548 m
549 }
550
551 #[test]
552 fn test_freshness_check_fresh_remote() {
553 let fetcher = MockFetcher {
554 remote: Some(make_signed_manifest()),
555 cached: None,
556 };
557 let result = check_manifest_freshness(&fetcher);
558 assert!(result.is_fresh);
559 assert_eq!(result.source, ManifestSource::FreshRemote);
560 assert!(result.warning.is_none());
561 }
562
563 #[test]
564 fn test_freshness_check_offline_with_cache() {
565 let fetcher = MockFetcher {
566 remote: None,
567 cached: Some(make_signed_manifest()),
568 };
569 let result = check_manifest_freshness(&fetcher);
570 assert!(!result.is_fresh);
571 assert_eq!(result.source, ManifestSource::CachedRemote);
572 assert!(result.warning.is_some());
573 assert!(result.refetch_triggered);
574 }
575
576 #[test]
577 fn test_freshness_check_offline_no_cache() {
578 let fetcher = MockFetcher {
579 remote: None,
580 cached: None,
581 };
582 let result = check_manifest_freshness(&fetcher);
583 assert!(!result.is_fresh);
584 assert_eq!(result.source, ManifestSource::Baseline);
585 assert!(result.warning.is_some());
586 }
587}