Skip to main content

a3s_box_runtime/local_execution/
oci_production.rs

1//! Production Box-owned bundle preparation for the native Linux OCI service.
2
3use std::path::{Path, PathBuf};
4
5use a3s_box_core::config::{ResourceConfig, TeeConfig};
6use a3s_box_core::{
7    BoxError, ExecutionBackend, ExecutionIsolation, ExecutionManagerError, ExecutionManagerResult,
8    NetworkMode,
9};
10use a3s_oci_sdk::{CreateAttachments, IoMode, IsolationRequest, OciBundle, ProcessIo};
11use async_trait::async_trait;
12
13use super::{
14    OciBundlePreparationContext, OciBundleProvider, OciPreparedExecution, VmLocalExecutionBackend,
15};
16use crate::sandbox::probe_sandbox_capabilities_for;
17use crate::BoxRecord;
18
19/// Prepares immutable bundles from Box image/rootfs policy while the separate
20/// A3S OCI Runtime service owns container lifecycle and I/O.
21#[derive(Clone)]
22pub struct NativeLinuxOciBundleProvider {
23    preparer: VmLocalExecutionBackend,
24    runtime_path: PathBuf,
25    agent_path: PathBuf,
26}
27
28/// Qualification-only producer for OCI Runtime's Windows dedicated-VM service.
29#[derive(Clone)]
30pub struct WindowsWhpxOciBundleProvider {
31    preparer: VmLocalExecutionBackend,
32    runtime_root: PathBuf,
33}
34
35impl WindowsWhpxOciBundleProvider {
36    pub fn new(home_dir: impl Into<PathBuf>, runtime_root: impl Into<PathBuf>) -> Self {
37        Self {
38            preparer: VmLocalExecutionBackend::new(home_dir),
39            runtime_root: runtime_root.into(),
40        }
41    }
42
43    pub fn runtime_root(&self) -> &Path {
44        &self.runtime_root
45    }
46
47    pub fn with_pull_progress_fn(mut self, pull_progress_fn: crate::PullProgressFn) -> Self {
48        self.preparer = self.preparer.with_pull_progress_fn(pull_progress_fn);
49        self
50    }
51}
52
53impl NativeLinuxOciBundleProvider {
54    pub fn new(
55        home_dir: impl Into<PathBuf>,
56        runtime_path: impl Into<PathBuf>,
57        agent_path: impl Into<PathBuf>,
58    ) -> Self {
59        Self {
60            preparer: VmLocalExecutionBackend::new(home_dir),
61            runtime_path: runtime_path.into(),
62            agent_path: agent_path.into(),
63        }
64    }
65
66    pub fn runtime_path(&self) -> &Path {
67        &self.runtime_path
68    }
69
70    pub fn agent_path(&self) -> &Path {
71        &self.agent_path
72    }
73
74    pub fn with_pull_progress_fn(mut self, pull_progress_fn: crate::PullProgressFn) -> Self {
75        self.preparer = self.preparer.with_pull_progress_fn(pull_progress_fn);
76        self
77    }
78}
79
80#[async_trait]
81impl OciBundleProvider for NativeLinuxOciBundleProvider {
82    async fn prepare(
83        &self,
84        record: &BoxRecord,
85        _context: &OciBundlePreparationContext,
86    ) -> ExecutionManagerResult<OciPreparedExecution> {
87        if record.isolation != ExecutionIsolation::Sandbox {
88            return Err(ExecutionManagerError::InvalidRequest(format!(
89                "native Linux OCI migration only prepares Sandbox executions, got {:?}",
90                record.isolation
91            )));
92        }
93        let metadata = record.managed_execution.as_ref().ok_or_else(|| {
94            ExecutionManagerError::Internal(format!(
95                "execution {} has no managed lifecycle metadata",
96                record.id
97            ))
98        })?;
99        metadata
100            .validate()
101            .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?;
102        if metadata.plan.backend != ExecutionBackend::A3sOci {
103            return Err(ExecutionManagerError::InvalidRequest(format!(
104                "execution {} did not resolve to the A3S OCI Sandbox backend",
105                record.id
106            )));
107        }
108
109        // Re-hash the exact configured artifacts immediately before rootfs or
110        // bundle mutations. Owner startup and bundle evidence use this same pair.
111        let capabilities = probe_sandbox_capabilities_for(
112            ExecutionBackend::A3sOci,
113            Some(&self.runtime_path),
114            Some(&self.agent_path),
115        );
116        capabilities
117            .require_ready()
118            .map_err(|error| preparation_error("capability preflight", error))?;
119
120        let mut manager = self.preparer.new_oci_preparation_manager(record)?;
121        let prepared = manager
122            .prepare_runtime_owned_sandbox_bundle(&metadata.plan, &capabilities)
123            .await
124            .map_err(|error| preparation_error("prepare bundle", error))?;
125        let bundle = match OciBundle::load(&prepared.bundle_dir).await {
126            Ok(bundle) => bundle,
127            Err(error) => {
128                let cleanup = manager.cleanup_runtime_owned_sandbox_bundle();
129                return Err(match cleanup {
130                    Ok(()) => ExecutionManagerError::Internal(format!(
131                        "failed to load the generated OCI bundle: {error}"
132                    )),
133                    Err(cleanup) => ExecutionManagerError::Internal(format!(
134                        "failed to load the generated OCI bundle: {error}; cleanup also failed: {cleanup}"
135                    )),
136                });
137            }
138        };
139        let io = ProcessIo {
140            stdin: if metadata.request.config.stdin_open {
141                IoMode::Pipe
142            } else {
143                IoMode::Null
144            },
145            stdout: IoMode::Capture,
146            stderr: IoMode::Capture,
147            terminal_size: None,
148        };
149        let attachments = match CreateAttachments::from_bundle(&bundle, io) {
150            Ok(attachments) => attachments,
151            Err(error) => {
152                return Err(cleanup_after_prepare_failure(
153                    &manager,
154                    format!("failed to derive generated OCI bundle attachments: {error}"),
155                ));
156            }
157        };
158        let mut result = match OciPreparedExecution::with_attachments(
159            bundle,
160            attachments,
161            prepared.console_output,
162        ) {
163            Ok(result) => result,
164            Err(error) => {
165                return Err(cleanup_after_prepare_failure(
166                    &manager,
167                    format!("failed to validate generated OCI bundle attachments: {error}"),
168                ));
169            }
170        };
171        result.anonymous_volumes = prepared.anonymous_volumes;
172        Ok(result)
173    }
174
175    async fn cleanup(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
176        let manager = self.preparer.new_oci_preparation_manager(record)?;
177        manager
178            .cleanup_runtime_owned_sandbox_bundle()
179            .map_err(|error| preparation_error("cleanup bundle", error))
180    }
181
182    async fn ensure_log_projection(
183        &self,
184        record: &BoxRecord,
185        binding: &super::OciRuntimeBinding,
186    ) -> ExecutionManagerResult<()> {
187        super::oci_log_projection::ensure(record, binding).await
188    }
189
190    async fn wait_log_projection_drained(
191        &self,
192        record: &BoxRecord,
193        binding: &super::OciRuntimeBinding,
194    ) -> ExecutionManagerResult<()> {
195        super::oci_log_projection::wait_drained(record, binding).await
196    }
197
198    async fn wait_log_projection_stopped_after_owner_loss(
199        &self,
200        record: &BoxRecord,
201        binding: &super::OciRuntimeBinding,
202    ) -> ExecutionManagerResult<()> {
203        super::oci_log_projection::wait_stopped_after_owner_loss(record, binding).await
204    }
205}
206
207#[async_trait]
208impl OciBundleProvider for WindowsWhpxOciBundleProvider {
209    fn preflight(
210        &self,
211        record: &BoxRecord,
212        context: &OciBundlePreparationContext,
213    ) -> ExecutionManagerResult<()> {
214        if !cfg!(all(target_os = "windows", target_arch = "x86_64")) {
215            return Err(ExecutionManagerError::Unavailable(
216                "Box/WHPX OCI qualification requires Windows x86_64".to_string(),
217            ));
218        }
219        if record.isolation != ExecutionIsolation::Microvm
220            || !matches!(context.isolation(), IsolationRequest::DedicatedVm)
221        {
222            return Err(ExecutionManagerError::InvalidRequest(
223                "Box/WHPX OCI qualification requires dedicated MicroVM isolation".to_string(),
224            ));
225        }
226        let metadata = record.managed_execution.as_ref().ok_or_else(|| {
227            ExecutionManagerError::Internal(format!(
228                "execution {} has no managed lifecycle metadata",
229                record.id
230            ))
231        })?;
232        metadata
233            .validate()
234            .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?;
235        if metadata.plan.backend != ExecutionBackend::Krun {
236            return Err(ExecutionManagerError::InvalidRequest(format!(
237                "execution {} did not resolve to the MicroVM backend",
238                record.id
239            )));
240        }
241        validate_whpx_qualification(record)?;
242        context.runtime_bundle_handoff_directory(&self.runtime_root)?;
243        validate_runtime_root(&self.runtime_root)
244    }
245
246    async fn prepare(
247        &self,
248        record: &BoxRecord,
249        context: &OciBundlePreparationContext,
250    ) -> ExecutionManagerResult<OciPreparedExecution> {
251        self.preflight(record, context)?;
252        let metadata = record.managed_execution.as_ref().ok_or_else(|| {
253            ExecutionManagerError::Internal(format!(
254                "execution {} has no managed lifecycle metadata",
255                record.id
256            ))
257        })?;
258        let bundle_directory = context.runtime_bundle_handoff_directory(&self.runtime_root)?;
259        let mut manager = self.preparer.new_oci_preparation_manager(record)?;
260        let prepared = manager
261            .prepare_runtime_owned_microvm_bundle(&metadata.plan, &bundle_directory)
262            .await
263            .map_err(|error| whpx_preparation_error("prepare bundle", error))?;
264        let bundle = match OciBundle::load(&prepared.bundle_dir).await {
265            Ok(bundle) => bundle,
266            Err(error) => {
267                return Err(cleanup_after_whpx_prepare_failure(
268                    &manager,
269                    &bundle_directory,
270                    format!("failed to load the generated portable OCI bundle: {error}"),
271                ));
272            }
273        };
274        let io = ProcessIo {
275            stdin: if metadata.request.config.stdin_open {
276                IoMode::Pipe
277            } else {
278                IoMode::Null
279            },
280            stdout: IoMode::Capture,
281            stderr: IoMode::Capture,
282            terminal_size: None,
283        };
284        let attachments = match CreateAttachments::from_bundle(&bundle, io) {
285            Ok(attachments) => attachments,
286            Err(error) => {
287                return Err(cleanup_after_whpx_prepare_failure(
288                    &manager,
289                    &bundle_directory,
290                    format!("failed to derive portable OCI bundle attachments: {error}"),
291                ));
292            }
293        };
294        let mut result = match OciPreparedExecution::with_attachments(
295            bundle,
296            attachments,
297            prepared.console_output,
298        ) {
299            Ok(result) => result,
300            Err(error) => {
301                return Err(cleanup_after_whpx_prepare_failure(
302                    &manager,
303                    &bundle_directory,
304                    format!("failed to validate portable OCI bundle attachments: {error}"),
305                ));
306            }
307        };
308        result = match result.with_runtime_bundle_handoff(context, &self.runtime_root) {
309            Ok(result) => result,
310            Err(error) => {
311                return Err(cleanup_after_whpx_prepare_failure(
312                    &manager,
313                    &bundle_directory,
314                    format!("failed to bind portable OCI bundle handoff: {error}"),
315                ));
316            }
317        };
318        result.anonymous_volumes = prepared.anonymous_volumes;
319        Ok(result)
320    }
321
322    async fn cleanup(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
323        let manager = self.preparer.new_oci_preparation_manager(record)?;
324        manager
325            .cleanup_runtime_owned_microvm_bundle()
326            .map_err(|error| whpx_preparation_error("cleanup bundle", error))
327    }
328
329    async fn ensure_log_projection(
330        &self,
331        record: &BoxRecord,
332        binding: &super::OciRuntimeBinding,
333    ) -> ExecutionManagerResult<()> {
334        super::oci_log_projection::ensure(record, binding).await
335    }
336
337    async fn wait_log_projection_drained(
338        &self,
339        record: &BoxRecord,
340        binding: &super::OciRuntimeBinding,
341    ) -> ExecutionManagerResult<()> {
342        super::oci_log_projection::wait_drained(record, binding).await
343    }
344
345    async fn wait_log_projection_stopped_after_owner_loss(
346        &self,
347        record: &BoxRecord,
348        binding: &super::OciRuntimeBinding,
349    ) -> ExecutionManagerResult<()> {
350        super::oci_log_projection::wait_stopped_after_owner_loss(record, binding).await
351    }
352}
353
354fn validate_whpx_qualification(record: &BoxRecord) -> ExecutionManagerResult<()> {
355    let metadata = record.managed_execution.as_ref().ok_or_else(|| {
356        ExecutionManagerError::Internal(format!(
357            "execution {} has no managed lifecycle metadata",
358            record.id
359        ))
360    })?;
361    let config = &metadata.request.config;
362    let defaults = ResourceConfig::default();
363    if config.resources.vcpus != 1 || config.resources.memory_mb != 512 {
364        return Err(unqualified(
365            "the fixed WHPX profile requires exactly 1 vCPU and 512 MiB of memory",
366        ));
367    }
368    if config.resources.disk_mb != defaults.disk_mb || config.resources.timeout != defaults.timeout
369    {
370        return Err(unqualified(
371            "custom disk size or lifetime timeout is not qualified for the WHPX OCI profile",
372        ));
373    }
374    if config.tee != TeeConfig::None {
375        return Err(unqualified("TEE is not qualified for the WHPX OCI profile"));
376    }
377    if !config.workspace.as_os_str().is_empty()
378        || !config.volumes.is_empty()
379        || config.virtiofs_cache.is_some()
380        || !metadata.request.policy.volume_names.is_empty()
381        || metadata.request.policy.managed_secret_root.is_some()
382    {
383        return Err(unqualified(
384            "workspace, bind, named, and secret mounts are not qualified for the WHPX OCI profile",
385        ));
386    }
387    if !matches!(config.network, NetworkMode::None)
388        || !matches!(record.network_mode, NetworkMode::None)
389        || !config.port_map.is_empty()
390        || !config.dns.is_empty()
391        || !config.add_hosts.is_empty()
392    {
393        return Err(unqualified(
394            "the WHPX OCI profile requires network=none and no network customization",
395        ));
396    }
397    if config.pool.enabled
398        || config.pool.snapshot_fork
399        || config.deferred_main
400        || config.ksm
401        || config.snapshot_mem_file.is_some()
402        || config.snapshot_sock.is_some()
403        || config.restore_from.is_some()
404        || metadata.request.rootfs_snapshot_id.is_some()
405    {
406        return Err(unqualified(
407            "pool, deferred-main, KSM, and Snapshot modes are not qualified for the WHPX OCI profile",
408        ));
409    }
410    if !config.tmpfs.is_empty()
411        || config.resource_limits != Default::default()
412        || !config.cap_add.is_empty()
413        || !config.cap_drop.is_empty()
414        || !config.security_opt.is_empty()
415        || !config.sysctls.is_empty()
416        || config.privileged
417        || config.read_only
418        || config.sidecar.is_some()
419        || config.persistent
420    {
421        return Err(unqualified(
422            "custom mounts, controls, privileges, sidecars, and persistence are not qualified for the WHPX OCI profile",
423        ));
424    }
425    let policy = &metadata.request.policy;
426    if policy.init
427        || !policy.devices.is_empty()
428        || policy.gpus.is_some()
429        || policy.shm_size.is_some()
430        || policy.oom_kill_disable
431        || policy.oom_score_adj.is_some()
432    {
433        return Err(unqualified(
434            "init, device, GPU, shared-memory, and OOM overrides are not qualified for the WHPX OCI profile",
435        ));
436    }
437    if policy.platform.as_deref().is_some_and(|platform| {
438        !matches!(
439            platform.trim().to_ascii_lowercase().as_str(),
440            "linux/amd64" | "linux/x86_64"
441        )
442    }) {
443        return Err(unqualified(
444            "the WHPX OCI profile supports only Linux amd64 images",
445        ));
446    }
447    if record.cpus != 1 || record.memory_mb != 512 {
448        return Err(ExecutionManagerError::Internal(
449            "Box record resources drifted from the fixed WHPX qualification profile".to_string(),
450        ));
451    }
452    Ok(())
453}
454
455fn unqualified(message: &str) -> ExecutionManagerError {
456    ExecutionManagerError::InvalidRequest(format!("Box/WHPX OCI qualification rejected: {message}"))
457}
458
459fn validate_runtime_root(path: &Path) -> ExecutionManagerResult<()> {
460    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
461        ExecutionManagerError::Unavailable(format!(
462            "failed to inspect WHPX runtime root {}: {error}",
463            path.display()
464        ))
465    })?;
466    if !metadata.is_dir() || metadata_is_reparse_point(&metadata) {
467        return Err(ExecutionManagerError::InvalidRequest(format!(
468            "WHPX runtime root is not a plain directory: {}",
469            path.display()
470        )));
471    }
472    Ok(())
473}
474
475#[cfg(windows)]
476fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
477    use std::os::windows::fs::MetadataExt as _;
478    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
479    metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
480}
481
482#[cfg(not(windows))]
483fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
484    metadata.file_type().is_symlink()
485}
486
487fn whpx_preparation_error(action: &str, error: BoxError) -> ExecutionManagerError {
488    match error {
489        BoxError::ConfigError(message) => ExecutionManagerError::InvalidRequest(message),
490        error => {
491            ExecutionManagerError::Unavailable(format!("Box/WHPX OCI {action} failed: {error}"))
492        }
493    }
494}
495
496fn cleanup_after_whpx_prepare_failure(
497    manager: &crate::VmManager,
498    bundle_directory: &Path,
499    message: String,
500) -> ExecutionManagerError {
501    let bundle_cleanup = remove_scoped_bundle(bundle_directory);
502    let rootfs_cleanup = manager.cleanup_runtime_owned_microvm_bundle();
503    match (bundle_cleanup, rootfs_cleanup) {
504        (Ok(()), Ok(())) => ExecutionManagerError::Internal(message),
505        (bundle, rootfs) => ExecutionManagerError::Internal(format!(
506            "{message}; cleanup also failed: handoff={bundle:?}, rootfs={rootfs:?}"
507        )),
508    }
509}
510
511fn remove_scoped_bundle(bundle_directory: &Path) -> std::io::Result<()> {
512    if bundle_directory.file_name().and_then(|name| name.to_str()) != Some("bundle")
513        || bundle_directory.parent().and_then(Path::parent).is_none()
514    {
515        return Err(std::io::Error::new(
516            std::io::ErrorKind::InvalidInput,
517            format!(
518                "refusing to remove an unscoped bundle: {}",
519                bundle_directory.display()
520            ),
521        ));
522    }
523    let metadata = match std::fs::symlink_metadata(bundle_directory) {
524        Ok(metadata) => metadata,
525        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
526        Err(error) => return Err(error),
527    };
528    if !metadata.is_dir() || metadata_is_reparse_point(&metadata) {
529        return Err(std::io::Error::new(
530            std::io::ErrorKind::InvalidData,
531            format!(
532                "bundle is not a plain directory: {}",
533                bundle_directory.display()
534            ),
535        ));
536    }
537    std::fs::remove_dir_all(bundle_directory)
538}
539
540fn preparation_error(action: &str, error: BoxError) -> ExecutionManagerError {
541    match error {
542        BoxError::ConfigError(message) => ExecutionManagerError::InvalidRequest(message),
543        error => {
544            ExecutionManagerError::Unavailable(format!("native Linux OCI {action} failed: {error}"))
545        }
546    }
547}
548
549fn cleanup_after_prepare_failure(
550    manager: &crate::VmManager,
551    message: String,
552) -> ExecutionManagerError {
553    match manager.cleanup_runtime_owned_sandbox_bundle() {
554        Ok(()) => ExecutionManagerError::Internal(message),
555        Err(cleanup) => {
556            ExecutionManagerError::Internal(format!("{message}; cleanup also failed: {cleanup}"))
557        }
558    }
559}