Skip to main content

a3s_box_runtime/local_execution/
oci_migration.rs

1//! Explicit production composition for Sandbox migration to A3S OCI Runtime.
2
3use std::ffi::OsString;
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7#[cfg(target_os = "linux")]
8use a3s_box_core::ExecutionBackend;
9use a3s_box_core::{ExecutionManagerError, ExecutionManagerResult};
10#[cfg(target_os = "linux")]
11use sha2::{Digest, Sha256};
12
13use super::LocalExecutionManager;
14#[cfg(target_os = "linux")]
15use super::{NativeLinuxOciBundleProvider, OciLocalExecutionBackend, OciMigrationPolicy};
16
17pub const OCI_MIGRATION_ENV: &str = "A3S_BOX_OCI_MIGRATION";
18pub const OCI_HOST_ROOT_ENV: &str = "A3S_BOX_OCI_HOST_ROOT";
19pub const OCI_RUNTIME_PATH_ENV: &str = "A3S_BOX_OCI_RUNTIME_PATH";
20pub const OCI_AGENT_PATH_ENV: &str = "A3S_BOX_OCI_AGENT_PATH";
21pub const OCI_WHPX_ENDPOINT_ENV: &str = "A3S_BOX_OCI_WHPX_ENDPOINT";
22#[cfg(test)]
23const DEFAULT_OCI_WHPX_ENDPOINT: &str = r"\\.\pipe\a3s-oci-box-qualification";
24
25/// Explicit native-Linux owner and artifact selection for Sandbox migration.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct NativeLinuxOciMigrationConfig {
28    service_root: PathBuf,
29    runtime_path: Option<PathBuf>,
30    agent_path: Option<PathBuf>,
31}
32
33/// Explicit connection to an externally owned qualification-only WHPX service.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct WindowsWhpxOciMigrationConfig {
36    runtime_root: PathBuf,
37    endpoint: super::OciRuntimeEndpoint,
38}
39
40impl WindowsWhpxOciMigrationConfig {
41    pub fn new(
42        runtime_root: impl Into<PathBuf>,
43        endpoint: impl Into<String>,
44    ) -> ExecutionManagerResult<Self> {
45        let config = Self {
46            runtime_root: runtime_root.into(),
47            endpoint: super::OciRuntimeEndpoint::windows_named_pipe(endpoint)?,
48        };
49        config.validate()?;
50        Ok(config)
51    }
52
53    pub fn runtime_root(&self) -> &Path {
54        &self.runtime_root
55    }
56
57    pub fn endpoint(&self) -> &super::OciRuntimeEndpoint {
58        &self.endpoint
59    }
60
61    pub fn from_environment(home_dir: &Path) -> ExecutionManagerResult<Option<Self>> {
62        parse_windows_environment(
63            std::env::var_os(OCI_MIGRATION_ENV),
64            std::env::var_os(OCI_HOST_ROOT_ENV),
65            std::env::var_os(OCI_WHPX_ENDPOINT_ENV),
66            home_dir,
67        )
68    }
69
70    fn validate(&self) -> ExecutionManagerResult<()> {
71        validate_absolute_normalized(&self.runtime_root, "WHPX OCI runtime root")?;
72        match &self.endpoint {
73            super::OciRuntimeEndpoint::WindowsNamedPipe { .. } => Ok(()),
74            super::OciRuntimeEndpoint::UnixSocket { .. } => {
75                Err(ExecutionManagerError::InvalidRequest(
76                    "WHPX OCI qualification requires a Windows named-pipe endpoint".to_string(),
77                ))
78            }
79        }
80    }
81}
82
83impl NativeLinuxOciMigrationConfig {
84    pub fn new(service_root: impl Into<PathBuf>) -> ExecutionManagerResult<Self> {
85        let config = Self {
86            service_root: service_root.into(),
87            runtime_path: None,
88            agent_path: None,
89        };
90        config.validate()?;
91        Ok(config)
92    }
93
94    pub fn with_artifacts(
95        mut self,
96        runtime_path: impl Into<PathBuf>,
97        agent_path: impl Into<PathBuf>,
98    ) -> ExecutionManagerResult<Self> {
99        self.runtime_path = Some(runtime_path.into());
100        self.agent_path = Some(agent_path.into());
101        self.validate()?;
102        Ok(self)
103    }
104
105    pub fn service_root(&self) -> &Path {
106        &self.service_root
107    }
108
109    pub fn runtime_path(&self) -> Option<&Path> {
110        self.runtime_path.as_deref()
111    }
112
113    pub fn agent_path(&self) -> Option<&Path> {
114        self.agent_path.as_deref()
115    }
116
117    /// Parse the process-wide opt-in. An absent/off value preserves the
118    /// existing VM backend without probing or starting an OCI owner.
119    pub fn from_environment(home_dir: &Path) -> ExecutionManagerResult<Option<Self>> {
120        parse_environment(
121            std::env::var_os(OCI_MIGRATION_ENV),
122            std::env::var_os(OCI_HOST_ROOT_ENV),
123            std::env::var_os(OCI_RUNTIME_PATH_ENV),
124            std::env::var_os(OCI_AGENT_PATH_ENV),
125            home_dir,
126        )
127    }
128
129    fn validate(&self) -> ExecutionManagerResult<()> {
130        validate_absolute_normalized(&self.service_root, "OCI host root")?;
131        match (&self.runtime_path, &self.agent_path) {
132            (Some(runtime), Some(agent)) => {
133                validate_absolute_normalized(runtime, "OCI runtime path")?;
134                validate_absolute_normalized(agent, "OCI agent path")?;
135            }
136            (None, None) => {}
137            _ => {
138                return Err(ExecutionManagerError::InvalidRequest(
139                    "OCI runtime and agent paths must be supplied together".to_string(),
140                ))
141            }
142        }
143        Ok(())
144    }
145}
146
147impl LocalExecutionManager {
148    /// Compose the retained VM backend with the production native-Linux OCI
149    /// owner and bundle provider. Only new Sandbox reservations use OCI.
150    pub async fn with_native_linux_oci_migration(
151        state_path: impl Into<PathBuf>,
152        home_dir: impl Into<PathBuf>,
153        config: NativeLinuxOciMigrationConfig,
154    ) -> ExecutionManagerResult<Self> {
155        Self::with_native_linux_oci_migration_and_pull_progress(
156            state_path.into(),
157            home_dir.into(),
158            config,
159            None,
160        )
161        .await
162    }
163
164    async fn with_native_linux_oci_migration_and_pull_progress(
165        state_path: PathBuf,
166        home_dir: PathBuf,
167        config: NativeLinuxOciMigrationConfig,
168        pull_progress_fn: Option<crate::PullProgressFn>,
169    ) -> ExecutionManagerResult<Self> {
170        config.validate()?;
171
172        #[cfg(target_os = "linux")]
173        {
174            let capabilities = crate::sandbox::probe_sandbox_capabilities_for(
175                ExecutionBackend::A3sOci,
176                config.runtime_path(),
177                config.agent_path(),
178            );
179            capabilities.require_ready().map_err(|error| {
180                ExecutionManagerError::Unavailable(format!(
181                    "native Linux OCI migration preflight failed: {error}"
182                ))
183            })?;
184            let artifacts = capabilities.a3s_oci.as_ref().ok_or_else(|| {
185                ExecutionManagerError::Unavailable(
186                    "native Linux OCI migration preflight returned no runtime artifacts"
187                        .to_string(),
188                )
189            })?;
190            let endpoint =
191                super::oci_owner::ensure_native_linux_oci_owner(config.service_root(), artifacts)
192                    .await?;
193            let mut provider = NativeLinuxOciBundleProvider::new(
194                home_dir.clone(),
195                artifacts.runtime_path.clone(),
196                artifacts.agent_path.clone(),
197            );
198            if let Some(progress) = pull_progress_fn.as_ref() {
199                provider = provider.with_pull_progress_fn(progress.clone());
200            }
201            let provider = Arc::new(provider);
202            let oci = Arc::new(OciLocalExecutionBackend::connect(endpoint, provider).await?);
203            Ok(Self::with_oci_migration_backend_and_pull_progress(
204                state_path,
205                home_dir,
206                oci,
207                OciMigrationPolicy::SandboxViaOci,
208                pull_progress_fn,
209            ))
210        }
211
212        #[cfg(not(target_os = "linux"))]
213        {
214            let _ = (state_path, home_dir, config, pull_progress_fn);
215            Err(ExecutionManagerError::Unavailable(
216                "native Linux OCI migration is supported only on Linux".to_string(),
217            ))
218        }
219    }
220
221    /// Compose the retained backend with the externally launched Box/WHPX OCI service.
222    pub async fn with_windows_whpx_oci_qualification(
223        state_path: impl Into<PathBuf>,
224        home_dir: impl Into<PathBuf>,
225        config: WindowsWhpxOciMigrationConfig,
226    ) -> ExecutionManagerResult<Self> {
227        Self::with_windows_whpx_oci_qualification_and_pull_progress(
228            state_path.into(),
229            home_dir.into(),
230            config,
231            None,
232        )
233        .await
234    }
235
236    async fn with_windows_whpx_oci_qualification_and_pull_progress(
237        state_path: PathBuf,
238        home_dir: PathBuf,
239        config: WindowsWhpxOciMigrationConfig,
240        pull_progress_fn: Option<crate::PullProgressFn>,
241    ) -> ExecutionManagerResult<Self> {
242        config.validate()?;
243
244        #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
245        {
246            let mut provider =
247                super::WindowsWhpxOciBundleProvider::new(home_dir.clone(), config.runtime_root());
248            if let Some(progress) = pull_progress_fn.as_ref() {
249                provider = provider.with_pull_progress_fn(progress.clone());
250            }
251            let oci = Arc::new(
252                super::OciLocalExecutionBackend::connect(
253                    config.endpoint().clone(),
254                    Arc::new(provider),
255                )
256                .await?,
257            );
258            Ok(Self::with_oci_migration_backend_and_pull_progress(
259                state_path,
260                home_dir,
261                oci,
262                super::OciMigrationPolicy::AllViaOci,
263                pull_progress_fn,
264            ))
265        }
266
267        #[cfg(not(all(target_os = "windows", target_arch = "x86_64")))]
268        {
269            let _ = (state_path, home_dir, config, pull_progress_fn);
270            Err(ExecutionManagerError::Unavailable(
271                "Box/WHPX OCI qualification requires Windows x86_64".to_string(),
272            ))
273        }
274    }
275
276    /// Select the production migration composition only when explicitly opted
277    /// in through `A3S_BOX_OCI_MIGRATION=sandbox`.
278    pub async fn with_configured_backend(
279        state_path: impl Into<PathBuf>,
280        home_dir: impl Into<PathBuf>,
281    ) -> ExecutionManagerResult<Self> {
282        Self::with_configured_backend_and_pull_progress(state_path, home_dir, None).await
283    }
284
285    /// Configured construction retaining the CLI's image-pull progress hook on
286    /// both the legacy and migrated preparation paths.
287    pub async fn with_configured_backend_and_pull_progress(
288        state_path: impl Into<PathBuf>,
289        home_dir: impl Into<PathBuf>,
290        pull_progress_fn: Option<crate::PullProgressFn>,
291    ) -> ExecutionManagerResult<Self> {
292        let state_path = state_path.into();
293        let home_dir = home_dir.into();
294
295        #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
296        {
297            match WindowsWhpxOciMigrationConfig::from_environment(&home_dir)? {
298                Some(config) => {
299                    Self::with_windows_whpx_oci_qualification_and_pull_progress(
300                        state_path,
301                        home_dir,
302                        config,
303                        pull_progress_fn,
304                    )
305                    .await
306                }
307                None => legacy_backend(state_path, home_dir, pull_progress_fn),
308            }
309        }
310
311        #[cfg(not(all(target_os = "windows", target_arch = "x86_64")))]
312        {
313            match NativeLinuxOciMigrationConfig::from_environment(&home_dir)? {
314                Some(config) => {
315                    Self::with_native_linux_oci_migration_and_pull_progress(
316                        state_path,
317                        home_dir,
318                        config,
319                        pull_progress_fn,
320                    )
321                    .await
322                }
323                None => legacy_backend(state_path, home_dir, pull_progress_fn),
324            }
325        }
326    }
327}
328
329fn legacy_backend(
330    state_path: PathBuf,
331    home_dir: PathBuf,
332    pull_progress_fn: Option<crate::PullProgressFn>,
333) -> ExecutionManagerResult<LocalExecutionManager> {
334    let mut backend = crate::local_execution::VmLocalExecutionBackend::new(&home_dir);
335    if let Some(progress) = pull_progress_fn {
336        backend = backend.with_pull_progress_fn(progress);
337    }
338    Ok(LocalExecutionManager::new(
339        state_path,
340        home_dir,
341        Arc::new(backend),
342    ))
343}
344
345fn parse_environment(
346    mode: Option<OsString>,
347    service_root: Option<OsString>,
348    runtime_path: Option<OsString>,
349    agent_path: Option<OsString>,
350    home_dir: &Path,
351) -> ExecutionManagerResult<Option<NativeLinuxOciMigrationConfig>> {
352    let Some(mode) = mode.filter(|value| !value.is_empty()) else {
353        return Ok(None);
354    };
355    let mode = mode.to_str().ok_or_else(|| {
356        ExecutionManagerError::InvalidRequest(format!(
357            "{OCI_MIGRATION_ENV} must contain UTF-8 text"
358        ))
359    })?;
360    match mode.trim().to_ascii_lowercase().as_str() {
361        "" | "0" | "false" | "off" | "disabled" | "legacy" => return Ok(None),
362        "1" | "true" | "on" | "sandbox" | "sandbox-via-oci" => {}
363        "all" | "all-via-oci" => {
364            return Err(ExecutionManagerError::InvalidRequest(
365                "all-via-OCI migration is not qualified yet; use sandbox".to_string(),
366            ))
367        }
368        value => {
369            return Err(ExecutionManagerError::InvalidRequest(format!(
370                "unsupported {OCI_MIGRATION_ENV} value {value:?}; expected off or sandbox"
371            )))
372        }
373    }
374
375    let root = service_root
376        .filter(|value| !value.is_empty())
377        .map(PathBuf::from)
378        .unwrap_or_else(|| default_service_root(home_dir));
379    let mut config = NativeLinuxOciMigrationConfig::new(root)?;
380    match (
381        runtime_path.filter(|value| !value.is_empty()),
382        agent_path.filter(|value| !value.is_empty()),
383    ) {
384        (Some(runtime), Some(agent)) => {
385            config = config.with_artifacts(PathBuf::from(runtime), PathBuf::from(agent))?;
386        }
387        (None, None) => {}
388        _ => {
389            return Err(ExecutionManagerError::InvalidRequest(format!(
390                "{OCI_RUNTIME_PATH_ENV} and {OCI_AGENT_PATH_ENV} must be set together"
391            )))
392        }
393    }
394    Ok(Some(config))
395}
396
397fn parse_windows_environment(
398    mode: Option<OsString>,
399    runtime_root: Option<OsString>,
400    endpoint: Option<OsString>,
401    home_dir: &Path,
402) -> ExecutionManagerResult<Option<WindowsWhpxOciMigrationConfig>> {
403    let Some(mode) = mode.filter(|value| !value.is_empty()) else {
404        return Ok(None);
405    };
406    let mode = mode.to_str().ok_or_else(|| {
407        ExecutionManagerError::InvalidRequest(format!(
408            "{OCI_MIGRATION_ENV} must contain UTF-8 text"
409        ))
410    })?;
411    match mode.trim().to_ascii_lowercase().as_str() {
412        "" | "0" | "false" | "off" | "disabled" | "legacy" => return Ok(None),
413        "all" | "all-via-oci" | "microvm" | "microvm-via-oci" | "whpx" => {}
414        "1" | "true" | "on" | "sandbox" | "sandbox-via-oci" => {
415            return Err(ExecutionManagerError::InvalidRequest(
416                "Windows OCI qualification supports only MicroVM/WHPX routing; use all or microvm"
417                    .to_string(),
418            ))
419        }
420        value => {
421            return Err(ExecutionManagerError::InvalidRequest(format!(
422                "unsupported {OCI_MIGRATION_ENV} value {value:?}; expected off, microvm, or all"
423            )))
424        }
425    }
426
427    let runtime_root = runtime_root
428        .filter(|value| !value.is_empty())
429        .map(PathBuf::from)
430        .unwrap_or_else(|| default_service_root(home_dir));
431    let endpoint = endpoint
432        .filter(|value| !value.is_empty())
433        .ok_or_else(|| {
434            ExecutionManagerError::InvalidRequest(format!(
435            "{OCI_WHPX_ENDPOINT_ENV} must be set explicitly for the qualification-only WHPX service"
436        ))
437        })?
438        .into_string()
439        .map_err(|_| {
440            ExecutionManagerError::InvalidRequest(format!(
441                "{OCI_WHPX_ENDPOINT_ENV} must contain UTF-8 text"
442            ))
443        })?;
444    WindowsWhpxOciMigrationConfig::new(runtime_root, endpoint).map(Some)
445}
446
447fn default_service_root(home_dir: &Path) -> PathBuf {
448    #[cfg(target_os = "linux")]
449    {
450        use std::os::unix::ffi::OsStrExt as _;
451
452        // Keep runtime.sock below the small sockaddr_un limit while separating
453        // different A3S homes owned by the same UID.
454        let digest = Sha256::digest(home_dir.as_os_str().as_bytes());
455        // SAFETY: geteuid has no preconditions or failure result.
456        let uid = unsafe { libc::geteuid() };
457        std::env::temp_dir().join(format!("a3s-box-oci-{uid}-{}", hex::encode(&digest[..6])))
458    }
459
460    #[cfg(not(target_os = "linux"))]
461    home_dir.join("run").join("oci-host")
462}
463
464fn validate_absolute_normalized(path: &Path, label: &str) -> ExecutionManagerResult<()> {
465    if !path.is_absolute()
466        || path.parent().is_none()
467        || path
468            .components()
469            .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
470    {
471        return Err(ExecutionManagerError::InvalidRequest(format!(
472            "{label} must be an absolute normalized non-root path: {}",
473            path.display()
474        )));
475    }
476    Ok(())
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn absolute(name: &str) -> PathBuf {
484        std::env::temp_dir().join(name)
485    }
486
487    #[test]
488    fn environment_is_opt_in_and_rejects_unqualified_all_policy() {
489        let home = absolute("a3s-oci-config-home");
490        assert_eq!(
491            parse_environment(None, None, None, None, &home).unwrap(),
492            None
493        );
494        assert_eq!(
495            parse_environment(Some(OsString::from("off")), None, None, None, &home).unwrap(),
496            None
497        );
498        assert!(parse_environment(Some(OsString::from("all")), None, None, None, &home).is_err());
499    }
500
501    #[test]
502    fn environment_requires_artifact_pair_and_accepts_sandbox() {
503        let home = absolute("a3s-oci-config-home");
504        let runtime = absolute("a3s-oci");
505        let agent = absolute("a3s-oci-agent");
506        assert!(parse_environment(
507            Some(OsString::from("sandbox")),
508            None,
509            Some(runtime.clone().into_os_string()),
510            None,
511            &home
512        )
513        .is_err());
514        let config = parse_environment(
515            Some(OsString::from("sandbox")),
516            Some(absolute("a3s-oci-root").into_os_string()),
517            Some(runtime.clone().into_os_string()),
518            Some(agent.clone().into_os_string()),
519            &home,
520        )
521        .unwrap()
522        .unwrap();
523        assert_eq!(config.runtime_path(), Some(runtime.as_path()));
524        assert_eq!(config.agent_path(), Some(agent.as_path()));
525    }
526
527    #[test]
528    fn windows_environment_requires_explicit_pipe_and_accepts_microvm() {
529        let home = absolute("a3s-oci-config-home");
530        assert!(
531            parse_windows_environment(Some(OsString::from("all")), None, None, &home,).is_err()
532        );
533
534        let config = parse_windows_environment(
535            Some(OsString::from("microvm")),
536            Some(absolute("a3s-oci-whpx-root").into_os_string()),
537            Some(OsString::from(DEFAULT_OCI_WHPX_ENDPOINT)),
538            &home,
539        )
540        .unwrap()
541        .unwrap();
542        assert_eq!(
543            config.endpoint(),
544            &crate::local_execution::OciRuntimeEndpoint::windows_named_pipe(
545                DEFAULT_OCI_WHPX_ENDPOINT,
546            )
547            .unwrap()
548        );
549    }
550}