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