Skip to main content

rusty_stack/adapter/
legacy_adapter.rs

1//! Legacy adapter — invokes shell scripts with correct argument forwarding.
2//!
3//! This module provides:
4//! - [`LegacyAdapter`] — invokes `install_*.sh` scripts with correct arguments
5//!
6//! # Design
7//!
8//! The legacy adapter is the fallback executor for components that don't yet
9//! have Rust-native implementations. It discovers the correct shell script
10//! from the component registry, forwards arguments correctly, and captures
11//! stdout/stderr and exit codes for parity verification.
12//!
13//! # Validation Assertions
14//!
15//! - **VAL-MIGR-010**: No Rust adapter → falls back to install_*.sh with correct args
16//! - **VAL-MIGR-012**: Output format matches shell for migrated components
17//! - **VAL-MIGR-013**: Exit codes match shell for success/failure/partial cases
18
19use super::{Adapter, AdapterError, AdapterOutput};
20use crate::core::types::ExecutorKind;
21use std::path::PathBuf;
22use std::process::Command;
23use std::time::Instant;
24
25// ===========================================================================
26// LegacyAdapter
27// ===========================================================================
28
29/// Adapter that invokes legacy shell scripts for component installation.
30///
31/// The adapter discovers the correct `install_*.sh` script from the component
32/// registry and executes it via `bash` with correct argument forwarding.
33///
34/// # Script Discovery
35///
36/// For a component like `pytorch`, the adapter looks up the installer script
37/// from the registry (e.g., `install_pytorch_rocm.sh`) and resolves it
38/// relative to the configured scripts directory.
39///
40/// # Argument Forwarding
41///
42/// The adapter forwards the component version as an argument to the script:
43/// ```bash
44/// bash <scripts_dir>/install_pytorch_rocm.sh <version>
45/// ```
46///
47/// Some scripts may accept additional environment variables or flags;
48/// the adapter passes these through the process environment.
49pub struct LegacyAdapter {
50    /// Root directory containing install scripts.
51    scripts_dir: PathBuf,
52}
53
54impl LegacyAdapter {
55    /// Create a new legacy adapter with the given scripts directory.
56    pub fn new(scripts_dir: impl Into<PathBuf>) -> Self {
57        Self {
58            scripts_dir: scripts_dir.into(),
59        }
60    }
61
62    /// Resolve the script path for a component ID.
63    ///
64    /// Uses the component registry to find the installer script name,
65    /// then resolves it relative to the scripts directory.
66    pub fn resolve_script_path(&self, component_id: &str) -> Option<PathBuf> {
67        let info = crate::platform::registry::get_component(component_id)?;
68        if info.installer_script.is_empty() {
69            return None;
70        }
71        Some(self.scripts_dir.join(&info.installer_script))
72    }
73
74    /// Build the command for invoking the install script.
75    ///
76    /// The command is: `bash <script_path> <version>`
77    pub fn build_command(
78        &self,
79        component_id: &str,
80        version: &str,
81    ) -> Result<Command, AdapterError> {
82        let script_path = self.resolve_script_path(component_id).ok_or_else(|| {
83            AdapterError::AdapterNotFound {
84                component_id: component_id.to_string(),
85            }
86        })?;
87
88        let mut cmd = Command::new("bash");
89        cmd.arg(&script_path);
90        if !version.is_empty() {
91            cmd.arg(version);
92        }
93
94        Ok(cmd)
95    }
96}
97
98impl Adapter for LegacyAdapter {
99    fn execute(&self, component_id: &str, version: &str) -> Result<AdapterOutput, AdapterError> {
100        let start = Instant::now();
101
102        let script_path = self.resolve_script_path(component_id).ok_or_else(|| {
103            AdapterError::AdapterNotFound {
104                component_id: component_id.to_string(),
105            }
106        })?;
107
108        // Check if the script exists
109        if !script_path.exists() {
110            return Err(AdapterError::InvocationError {
111                component_id: component_id.to_string(),
112                reason: format!("script not found: {}", script_path.display()),
113            });
114        }
115
116        // Build and execute the command
117        let mut cmd = Command::new("bash");
118        cmd.arg(&script_path);
119        if !version.is_empty() {
120            cmd.arg(version);
121        }
122
123        // Capture stdout and stderr
124        let output = cmd.output().map_err(|e| AdapterError::InvocationError {
125            component_id: component_id.to_string(),
126            reason: format!("failed to execute script: {e}"),
127        })?;
128
129        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
130        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
131        let exit_code = output.status.code().unwrap_or(-1);
132        let duration_ms = start.elapsed().as_millis() as u64;
133
134        Ok(AdapterOutput {
135            component_id: component_id.to_string(),
136            executor_kind: ExecutorKind::LegacyScript,
137            stdout,
138            stderr,
139            exit_code,
140            duration_ms,
141        })
142    }
143
144    fn executor_kind(&self) -> ExecutorKind {
145        ExecutorKind::LegacyScript
146    }
147}
148
149// ===========================================================================
150// Tests
151// ===========================================================================
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    // -----------------------------------------------------------------------
158    // Test Helpers
159    // -----------------------------------------------------------------------
160
161    /// Create a temp directory with a mock install script.
162    fn create_mock_script_dir() -> (tempfile::TempDir, PathBuf) {
163        let dir = tempfile::tempdir().unwrap();
164        let scripts_dir = dir.path().to_path_buf();
165
166        // Create mock install scripts
167        std::fs::write(
168            scripts_dir.join("install_pytorch_rocm.sh"),
169            "#!/bin/bash\necho \"PyTorch $1 installed successfully\"\nexit 0\n",
170        )
171        .unwrap();
172
173        std::fs::write(
174            scripts_dir.join("install_triton_multi.sh"),
175            "#!/bin/bash\necho \"Triton $1 installed successfully\"\nexit 0\n",
176        )
177        .unwrap();
178
179        std::fs::write(
180            scripts_dir.join("install_flash_attention_ck.sh"),
181            "#!/bin/bash\necho \"Flash Attention $1 installed successfully\"\nexit 0\n",
182        )
183        .unwrap();
184
185        // A script that fails
186        std::fs::write(
187            scripts_dir.join("install_deepspeed.sh"),
188            "#!/bin/bash\necho \"Error: DeepSpeed $1 installation failed\" >&2\nexit 1\n",
189        )
190        .unwrap();
191
192        // A script that partially succeeds
193        std::fs::write(
194            scripts_dir.join("install_vllm_multi.sh"),
195            "#!/bin/bash\necho \"vLLM $1 partially installed\"\necho \"Warning: some deps missing\" >&2\nexit 2\n",
196        )
197        .unwrap();
198
199        (dir, scripts_dir)
200    }
201
202    // -----------------------------------------------------------------------
203    // VAL-MIGR-010: Legacy fallback with correct argument forwarding
204    // -----------------------------------------------------------------------
205
206    #[test]
207    fn test_legacy_adapter_forwards_version_argument() {
208        let (_dir, scripts_dir) = create_mock_script_dir();
209        let adapter = LegacyAdapter::new(&scripts_dir);
210
211        let result = adapter.execute("pytorch", "2.5.0").unwrap();
212        assert_eq!(result.exit_code, 0);
213        assert!(result.stdout.contains("2.5.0"));
214        assert!(result.stdout.contains("PyTorch"));
215    }
216
217    #[test]
218    fn test_legacy_adapter_forwards_correct_script_per_component() {
219        let (_dir, scripts_dir) = create_mock_script_dir();
220        let adapter = LegacyAdapter::new(&scripts_dir);
221
222        // PyTorch → install_pytorch_rocm.sh
223        let result = adapter.execute("pytorch", "2.5.0").unwrap();
224        assert!(result.stdout.contains("PyTorch"));
225
226        // Triton → install_triton_multi.sh
227        let result = adapter.execute("triton", "3.1.0").unwrap();
228        assert!(result.stdout.contains("Triton"));
229        assert!(result.stdout.contains("3.1.0"));
230    }
231
232    #[test]
233    fn test_legacy_adapter_forwards_multiple_components() {
234        let (_dir, scripts_dir) = create_mock_script_dir();
235        let adapter = LegacyAdapter::new(&scripts_dir);
236
237        // Flash Attention → install_flash_attention_ck.sh
238        let result = adapter.execute("flash-attn", "2.6.0").unwrap();
239        assert!(result.stdout.contains("Flash Attention"));
240        assert!(result.stdout.contains("2.6.0"));
241        assert_eq!(result.exit_code, 0);
242    }
243
244    // -----------------------------------------------------------------------
245    // VAL-MIGR-012: Output format matches shell
246    // -----------------------------------------------------------------------
247
248    #[test]
249    fn test_legacy_adapter_captures_stdout() {
250        let (_dir, scripts_dir) = create_mock_script_dir();
251        let adapter = LegacyAdapter::new(&scripts_dir);
252
253        let result = adapter.execute("pytorch", "2.5.0").unwrap();
254        assert!(!result.stdout.is_empty());
255        assert!(result.stdout.contains("installed"));
256    }
257
258    #[test]
259    fn test_legacy_adapter_captures_stderr() {
260        let (_dir, scripts_dir) = create_mock_script_dir();
261        let adapter = LegacyAdapter::new(&scripts_dir);
262
263        // DeepSpeed script writes to stderr
264        let result = adapter.execute("deepspeed", "0.14.0").unwrap();
265        assert!(!result.stderr.is_empty());
266        assert!(result.stderr.contains("Error"));
267    }
268
269    // -----------------------------------------------------------------------
270    // VAL-MIGR-013: Exit codes match shell for success/failure/partial
271    // -----------------------------------------------------------------------
272
273    #[test]
274    fn test_legacy_adapter_exit_code_success() {
275        let (_dir, scripts_dir) = create_mock_script_dir();
276        let adapter = LegacyAdapter::new(&scripts_dir);
277
278        let result = adapter.execute("pytorch", "2.5.0").unwrap();
279        assert_eq!(result.exit_code, 0);
280        assert!(result.is_success());
281    }
282
283    #[test]
284    fn test_legacy_adapter_exit_code_failure() {
285        let (_dir, scripts_dir) = create_mock_script_dir();
286        let adapter = LegacyAdapter::new(&scripts_dir);
287
288        let result = adapter.execute("deepspeed", "0.14.0").unwrap();
289        assert_eq!(result.exit_code, 1);
290        assert!(!result.is_success());
291    }
292
293    #[test]
294    fn test_legacy_adapter_exit_code_partial() {
295        let (_dir, scripts_dir) = create_mock_script_dir();
296        let adapter = LegacyAdapter::new(&scripts_dir);
297
298        let result = adapter.execute("vllm", "0.6.0").unwrap();
299        assert_eq!(result.exit_code, 2);
300        assert!(!result.is_success());
301        assert!(!result.stdout.is_empty());
302        assert!(!result.stderr.is_empty());
303    }
304
305    // -----------------------------------------------------------------------
306    // Script resolution
307    // -----------------------------------------------------------------------
308
309    #[test]
310    fn test_resolve_script_path_known_component() {
311        let (_dir, scripts_dir) = create_mock_script_dir();
312        let adapter = LegacyAdapter::new(&scripts_dir);
313
314        let path = adapter.resolve_script_path("pytorch");
315        assert!(path.is_some());
316        let path = path.unwrap();
317        assert!(path.to_string_lossy().contains("install_pytorch_rocm.sh"));
318    }
319
320    #[test]
321    fn test_resolve_script_path_unknown_component() {
322        let (_dir, scripts_dir) = create_mock_script_dir();
323        let adapter = LegacyAdapter::new(&scripts_dir);
324
325        let path = adapter.resolve_script_path("nonexistent");
326        assert!(path.is_none());
327    }
328
329    #[test]
330    fn test_resolve_script_path_no_installer() {
331        let (_dir, scripts_dir) = create_mock_script_dir();
332        let adapter = LegacyAdapter::new(&scripts_dir);
333
334        // rocm-smi has no installer script
335        let path = adapter.resolve_script_path("rocm-smi");
336        assert!(path.is_none());
337    }
338
339    // -----------------------------------------------------------------------
340    // Invocation errors
341    // -----------------------------------------------------------------------
342
343    #[test]
344    fn test_legacy_adapter_script_not_found() {
345        let dir = tempfile::tempdir().unwrap();
346        let adapter = LegacyAdapter::new(dir.path());
347
348        // pytorch has a registry entry but the script doesn't exist
349        let result = adapter.execute("pytorch", "2.5.0");
350        assert!(result.is_err());
351        let err = result.unwrap_err();
352        assert!(matches!(err, AdapterError::InvocationError { .. }));
353        assert!(err.to_string().contains("script not found"));
354    }
355
356    #[test]
357    fn test_legacy_adapter_unknown_component() {
358        let dir = tempfile::tempdir().unwrap();
359        let adapter = LegacyAdapter::new(dir.path());
360
361        let result = adapter.execute("nonexistent", "1.0.0");
362        assert!(matches!(result, Err(AdapterError::AdapterNotFound { .. })));
363    }
364
365    // -----------------------------------------------------------------------
366    // Command building
367    // -----------------------------------------------------------------------
368
369    #[test]
370    fn test_build_command_includes_version() {
371        let (_dir, scripts_dir) = create_mock_script_dir();
372        let adapter = LegacyAdapter::new(&scripts_dir);
373
374        let cmd = adapter.build_command("pytorch", "2.5.0").unwrap();
375        let formatted = format!("{cmd:?}");
376        assert!(formatted.contains("2.5.0"));
377        assert!(formatted.contains("install_pytorch_rocm.sh"));
378    }
379
380    #[test]
381    fn test_build_command_empty_version() {
382        let (_dir, scripts_dir) = create_mock_script_dir();
383        let adapter = LegacyAdapter::new(&scripts_dir);
384
385        // Empty version should not add an argument
386        let cmd = adapter.build_command("pytorch", "").unwrap();
387        let formatted = format!("{cmd:?}");
388        assert!(formatted.contains("install_pytorch_rocm.sh"));
389    }
390
391    // -----------------------------------------------------------------------
392    // Executor kind
393    // -----------------------------------------------------------------------
394
395    #[test]
396    fn test_legacy_adapter_executor_kind() {
397        let dir = tempfile::tempdir().unwrap();
398        let adapter = LegacyAdapter::new(dir.path());
399        assert_eq!(adapter.executor_kind(), ExecutorKind::LegacyScript);
400    }
401
402    // -----------------------------------------------------------------------
403    // Duration tracking
404    // -----------------------------------------------------------------------
405
406    #[test]
407    fn test_legacy_adapter_tracks_duration() {
408        let (_dir, scripts_dir) = create_mock_script_dir();
409        let adapter = LegacyAdapter::new(&scripts_dir);
410
411        let result = adapter.execute("pytorch", "2.5.0").unwrap();
412        // Duration should be tracked (u64 is always >= 0, just verify it's populated)
413        let _ = result.duration_ms;
414    }
415}