1use 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#[derive(Clone)]
22pub struct NativeLinuxOciBundleProvider {
23 preparer: VmLocalExecutionBackend,
24 runtime_path: PathBuf,
25 agent_path: PathBuf,
26}
27
28#[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 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}