Skip to main content

a3s_box_runtime/vm/
boot.rs

1//! VM boot transaction and guest-native rootfs handoff.
2
3use super::*;
4
5impl VmManager {
6    /// Boot the VM.
7    pub async fn boot(&mut self) -> Result<()> {
8        let boot_span = tracing::info_span!("vm_boot", box_id = %self.box_id);
9        // Check and transition state: Created → booting
10        {
11            let state = self.state.read().await;
12            if *state != BoxState::Created {
13                return Err(BoxError::StateError("VM already booted".to_string()));
14            }
15        }
16        super::validate_snapshot_launch(&self.config)?;
17
18        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
19        self.preserve_rootfs_on_boot_failure =
20            self.config.persistent && layout::persistent_rootfs_generation_exists(&box_dir)?;
21
22        let execution_plan = a3s_box_core::resolve_execution(&self.config)?;
23        self.resolved_execution_plan = Some(execution_plan.clone());
24        if execution_plan.backend.is_sandbox() {
25            let boot_start = std::time::Instant::now();
26            return self
27                .boot_sandbox(execution_plan, &boot_span, boot_start)
28                .await;
29        }
30
31        let boot_start = std::time::Instant::now();
32
33        tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM");
34
35        // 1. Prepare filesystem layout
36        let layout = match self
37            .prepare_layout()
38            .instrument(tracing::info_span!(parent: &boot_span, "prepare_layout"))
39            .await
40        {
41            Ok(layout) => layout,
42            Err(error) => {
43                self.cleanup_boot_failure().await;
44                return Err(error);
45            }
46        };
47        self.image_config = layout.oci_config.clone();
48
49        // `prepare_layout` may only now have mounted a Snapshot lower through
50        // this box's overlay. Stage via the exact guest-visible root so rename
51        // copy-ups into the per-box upper before any guest process can launch.
52        if !self.rootfs_provider.guest_owns_terminal_fencing() {
53            if let Err(error) =
54                a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(
55                    &layout.rootfs_path,
56                )
57            {
58                self.cleanup_boot_failure().await;
59                return Err(BoxError::IoError(error));
60            }
61        }
62
63        // 2. Build InstanceSpec
64        let mut spec = match self.build_microvm_instance_spec(&layout) {
65            Ok(s) => s,
66            Err(e) => {
67                self.cleanup_boot_failure().await;
68                return Err(e);
69            }
70        };
71
72        // 2.5. Configure bridge networking if requested
73        let bridge_network = match &self.config.network {
74            a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()),
75            _ => None,
76        };
77        if let Some(network_name) = bridge_network.as_deref() {
78            let net_config = match self.setup_bridge_network(network_name) {
79                Ok(n) => n,
80                Err(e) => {
81                    self.cleanup_boot_failure().await;
82                    return Err(e);
83                }
84            };
85
86            // Inject network env vars into entrypoint so they are passed via
87            // krun_set_exec's envp (not krun_set_env which overwrites all vars).
88            let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
89            spec.entrypoint
90                .env
91                .push(("A3S_NET_IP".to_string(), ip_cidr));
92            spec.entrypoint.env.push((
93                "A3S_NET_GATEWAY".to_string(),
94                net_config.gateway.to_string(),
95            ));
96            spec.entrypoint.env.push((
97                "A3S_NET_DNS".to_string(),
98                net_config
99                    .dns_servers
100                    .iter()
101                    .map(|s| s.to_string())
102                    .collect::<Vec<_>>()
103                    .join(","),
104            ));
105
106            spec.network = Some(net_config);
107        }
108
109        #[cfg(target_os = "macos")]
110        if spec.network.is_none()
111            && matches!(self.config.network, a3s_box_core::NetworkMode::Tsi)
112            && !self.config.port_map.is_empty()
113        {
114            let net_config = match self.setup_published_default_network() {
115                Ok(network) => network,
116                Err(error) => {
117                    self.cleanup_boot_failure().await;
118                    return Err(error);
119                }
120            };
121            let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
122            spec.entrypoint
123                .env
124                .push(("A3S_NET_IP".to_string(), ip_cidr));
125            spec.entrypoint.env.push((
126                "A3S_NET_GATEWAY".to_string(),
127                net_config.gateway.to_string(),
128            ));
129            spec.entrypoint.env.push((
130                "A3S_NET_DNS".to_string(),
131                net_config
132                    .dns_servers
133                    .iter()
134                    .map(ToString::to_string)
135                    .collect::<Vec<_>>()
136                    .join(","),
137            ));
138            spec.network = Some(net_config);
139        }
140
141        // Resolve all dynamic launch-time files only after network allocation.
142        // New guest-init images receive them through the private boot share;
143        // legacy images without guest-init retain the directory-root fallback.
144        let host_config = match self.guest_host_config(
145            bridge_network.as_deref(),
146            spec.network
147                .as_ref()
148                .map(|network| network.dns_servers.as_slice()),
149        ) {
150            Ok(config) => config,
151            Err(error) => {
152                self.cleanup_boot_failure().await;
153                return Err(error);
154            }
155        };
156        let uses_guest_boot_config =
157            match Self::finalize_microvm_guest_boot_config(&spec, host_config) {
158                Ok(value) => value,
159                Err(error) => {
160                    self.cleanup_boot_failure().await;
161                    return Err(error);
162                }
163            };
164        if layout.resumed_rootfs.is_some() && !uses_guest_boot_config {
165            self.cleanup_boot_failure().await;
166            return Err(BoxError::BoxBootError {
167                message: "guest-owned rootfs did not select the private guest boot transport"
168                    .to_string(),
169                hint: None,
170            });
171        }
172        if !uses_guest_boot_config {
173            let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns);
174            if let Err(error) = crate::oci::rootfs::write_guest_file(
175                &layout.rootfs_path,
176                "etc/resolv.conf",
177                &resolv_content,
178            ) {
179                self.cleanup_boot_failure().await;
180                return Err(error);
181            }
182            if let Err(error) = self.write_hostname_file(&layout) {
183                self.cleanup_boot_failure().await;
184                return Err(error);
185            }
186            let hosts_result = match bridge_network.as_deref() {
187                Some(network_name) => self.write_hosts_file(&layout, network_name),
188                None => self.write_standalone_hosts_file(&layout),
189            };
190            if let Err(error) = hosts_result {
191                self.cleanup_boot_failure().await;
192                return Err(error);
193            }
194        }
195
196        // Directory providers retain the compatibility host-side baseline.
197        // Guest-native providers capture it inside guest-init after ownership
198        // handoff, before any workload or sidecar process can mutate the disk.
199        if !self.rootfs_provider.guest_owns_diff_baseline() {
200            self.create_diff_baseline(&layout);
201        }
202
203        #[cfg(target_os = "macos")]
204        let artifact_cache = match self.rootfs_artifact_cache_options(
205            &layout,
206            &spec.entrypoint.executable,
207            uses_guest_boot_config,
208        ) {
209            Ok(cache) => cache,
210            Err(error) => {
211                self.cleanup_boot_failure().await;
212                return Err(error);
213            }
214        };
215        #[cfg(not(target_os = "macos"))]
216        let artifact_cache = None;
217
218        // This is the ownership boundary between a host-visible staging tree
219        // and the root filesystem presented to the guest. Providers may keep
220        // the directory transport, or atomically publish a guest-native block
221        // artifact after every host-side mutation is complete.
222        if let Some(resumed) = layout.resumed_rootfs.as_ref() {
223            spec.rootfs = resumed.source.clone();
224        } else {
225            spec.rootfs = match self.rootfs_provider.finalize_for_boot(
226                &box_dir,
227                &layout.rootfs_path,
228                crate::rootfs::RootfsFinalizeOptions {
229                    disk_mib: self.config.resources.disk_mb,
230                    persistent: self.config.persistent,
231                    snapshot: super::rootfs_snapshot_requested(&self.config),
232                    artifact_cache,
233                },
234            ) {
235                Ok(rootfs) => rootfs,
236                Err(error) => {
237                    self.cleanup_boot_failure().await;
238                    return Err(error);
239                }
240            };
241        }
242
243        // 3. Initialize VMM provider (use injected provider or default to VmController)
244        if self.provider.is_none() {
245            let shim_path = match VmController::find_shim() {
246                Ok(p) => p,
247                Err(e) => {
248                    self.cleanup_boot_failure().await;
249                    return Err(e);
250                }
251            };
252            let controller = match VmController::new(shim_path) {
253                Ok(c) => c,
254                Err(e) => {
255                    self.cleanup_boot_failure().await;
256                    return Err(e);
257                }
258            };
259            self.provider = Some(Box::new(controller));
260        }
261
262        // 4. Start VM via provider
263        let handler = {
264            let provider = self
265                .provider
266                .as_ref()
267                .ok_or_else(|| BoxError::BoxBootError {
268                    message: "VMM provider not initialized".to_string(),
269                    hint: Some("Ensure VmManager has a provider set before boot".to_string()),
270                })?;
271            let vm_start_span = tracing::info_span!(parent: &boot_span, "vm_start");
272            match async { provider.start(&spec).await }
273                .instrument(vm_start_span)
274                .await
275            {
276                Ok(h) => h,
277                Err(e) => {
278                    self.cleanup_boot_failure().await;
279                    return Err(e);
280                }
281            }
282        };
283
284        // Store handler
285        *self.handler.write().await = Some(handler);
286
287        // 5. Wait for guest ready
288        {
289            let wait_span = tracing::info_span!(parent: &boot_span, "wait_for_ready");
290            if let Err(e) = async {
291                self.wait_for_vm_running().await?;
292
293                // 5b. Become ready. A snapshot-restore boot resumes an already-booted
294                // guest whose exec server won't re-signal readiness, so the cold-boot
295                // wait would stall registration on its safety cap — do one best-effort
296                // probe instead. A normal boot waits for the Heartbeat health check.
297                #[cfg(unix)]
298                if is_restore_mode(&self.config) {
299                    self.probe_exec_ready_once(&layout.exec_socket_path).await;
300                } else {
301                    self.wait_for_exec_ready(&layout.exec_socket_path).await?;
302                }
303                #[cfg(windows)]
304                self.wait_for_exec_ready(&layout.exec_socket_path).await?;
305                Ok::<(), BoxError>(())
306            }
307            .instrument(wait_span)
308            .await
309            {
310                self.cleanup_boot_failure().await;
311                return Err(e);
312            }
313        }
314
315        if self.rootfs_provider.guest_owns_diff_baseline() {
316            if let Err(error) = crate::rootfs::publish_guest_diff_baseline(&box_dir) {
317                self.cleanup_boot_failure().await;
318                return Err(error);
319            }
320        }
321
322        // guest-init has consumed and unmounted the one-shot boot share before
323        // signalling readiness. Remove its host payload now so even a later
324        // privileged remount cannot recover workload environment data.
325        if let Err(error) = Self::clear_microvm_guest_boot_config(&spec) {
326            self.cleanup_boot_failure().await;
327            return Err(error);
328        }
329
330        // Prototype: deferred-main-spawn. The guest booted IDLE (BOX_DEFERRED_MAIN);
331        // now that the exec server is ready, tell it to spawn the container command
332        // (already passed via BOX_EXEC_*) as the MAIN process — full box semantics
333        // (exit code + json-file console logs) without a cold boot.
334        // Auto-trigger spawn-main only for the env-driven `run` path, where the
335        // command is known at boot. The pool sets config.deferred_main to boot the
336        // VM IDLE but drives spawn-main EXPLICITLY per request (the per-request
337        // command isn't known at pre-warm), so a pool VM must NOT auto-trigger here.
338        // A restored guest's main is ALREADY running (captured in the snapshot), so
339        // it must never re-spawn — doing so would start a duplicate main.
340        #[cfg(unix)]
341        if !is_restore_mode(&self.config)
342            && std::env::var("BOX_DEFERRED_MAIN")
343                .map(|v| v == "1")
344                .unwrap_or(false)
345        {
346            if let Some(client) = self.exec_client.as_ref() {
347                match client.spawn_main(None).await {
348                    Ok(true) => tracing::info!("deferred container main spawned"),
349                    Ok(false) => tracing::warn!("deferred spawn-main not acknowledged"),
350                    Err(e) => tracing::warn!(error = %e, "deferred spawn-main failed"),
351                }
352            }
353        }
354
355        // 5b2. Store socket paths for CRI streaming access
356        self.exec_socket_path = Some(layout.exec_socket_path.clone());
357        self.pty_socket_path = Some(layout.pty_socket_path.clone());
358        self.port_forward_socket_path = Some(layout.port_forward_socket_path.clone());
359
360        // 5c. Initialize TEE extension for TEE environments
361        #[cfg(unix)]
362        if !matches!(self.config.tee, TeeConfig::None) {
363            self.tee = Some(Box::new(crate::tee::SnpTeeExtension::new(
364                self.box_id.clone(),
365                layout.attest_socket_path.clone(),
366            )));
367        }
368
369        // 6. Update state to Ready
370        *self.state.write().await = BoxState::Ready;
371
372        // Record Prometheus metrics
373        if let Some(ref prom) = self.prom {
374            let boot_duration = boot_start.elapsed().as_secs_f64();
375            prom.vm_boot_duration.observe(boot_duration);
376            prom.vm_created_total.inc();
377            prom.vm_count.with_label_values(&["ready"]).inc();
378        }
379
380        // Emit ready event
381        self.event_emitter.emit(BoxEvent::empty("box.ready"));
382
383        tracing::info!(parent: &boot_span, box_id = %self.box_id, "VM ready");
384
385        Ok(())
386    }
387
388    #[cfg(target_os = "macos")]
389    fn rootfs_artifact_cache_options(
390        &self,
391        layout: &BoxLayout,
392        guest_executable: &str,
393        uses_guest_boot_config: bool,
394    ) -> Result<Option<crate::rootfs::RootfsArtifactCacheOptions>> {
395        if !self.rootfs_provider.supports_artifact_cache()
396            || !self.config.cache.enabled
397            || !uses_guest_boot_config
398            || layout.resumed_rootfs.is_some()
399        {
400            return Ok(None);
401        }
402        let Some(oci_manifest_digest) = layout.oci_manifest_digest.as_ref() else {
403            return Ok(None);
404        };
405        let relative = guest_executable.strip_prefix('/').ok_or_else(|| {
406            BoxError::BuildError(format!(
407                "guest-init executable is not absolute: {guest_executable}"
408            ))
409        })?;
410        let guest_init =
411            crate::oci::rootfs::resolve_guest_file_path(&layout.rootfs_path, relative)?;
412        let guest_init_sha256 = Self::guest_init_sha256(&guest_init)?;
413
414        let architecture = a3s_box_core::platform::Platform::host().architecture;
415        Ok(Some(crate::rootfs::RootfsArtifactCacheOptions {
416            directory: self.resolve_cache_dir().join("rootfs-ext4-v1"),
417            oci_manifest_digest: oci_manifest_digest.clone(),
418            platform: format!("linux/{architecture}"),
419            guest_init_sha256,
420            max_entries: self.config.cache.max_rootfs_entries,
421            max_allocated_bytes: self.config.cache.max_cache_bytes,
422        }))
423    }
424
425    pub(crate) fn guest_init_sha256(path: &std::path::Path) -> Result<String> {
426        use sha2::{Digest, Sha256};
427        use std::io::Read;
428
429        let path_metadata = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?;
430        let mut open_options = std::fs::OpenOptions::new();
431        open_options.read(true);
432        #[cfg(unix)]
433        {
434            use std::os::unix::fs::OpenOptionsExt;
435            open_options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
436        }
437        let mut file = open_options.open(path).map_err(BoxError::IoError)?;
438        let metadata = file.metadata().map_err(BoxError::IoError)?;
439        const MAX_GUEST_INIT_BYTES: u64 = 256 * 1024 * 1024;
440        if !path_metadata.is_file()
441            || path_metadata.file_type().is_symlink()
442            || !metadata.is_file()
443            || path_metadata.len() != metadata.len()
444            || metadata.len() > MAX_GUEST_INIT_BYTES
445        {
446            return Err(BoxError::BuildError(format!(
447                "guest-init cache identity source is not a bounded plain file: {}",
448                path.display()
449            )));
450        }
451        #[cfg(unix)]
452        {
453            use std::os::unix::fs::MetadataExt;
454            if path_metadata.dev() != metadata.dev() || path_metadata.ino() != metadata.ino() {
455                return Err(BoxError::BuildError(format!(
456                    "guest-init changed while opening cache identity source: {}",
457                    path.display()
458                )));
459            }
460        }
461        let expected_length = metadata.len();
462        let mut read_length = 0u64;
463        let mut hasher = Sha256::new();
464        let mut buffer = vec![0u8; 1024 * 1024];
465        {
466            let mut bounded = file.by_ref().take(MAX_GUEST_INIT_BYTES + 1);
467            loop {
468                let read = bounded.read(&mut buffer).map_err(BoxError::IoError)?;
469                if read == 0 {
470                    break;
471                }
472                read_length = read_length.checked_add(read as u64).ok_or_else(|| {
473                    BoxError::BuildError("guest-init length overflow".to_string())
474                })?;
475                if read_length > expected_length {
476                    return Err(BoxError::BuildError(format!(
477                        "guest-init changed while computing cache identity: {}",
478                        path.display()
479                    )));
480                }
481                hasher.update(&buffer[..read]);
482            }
483        }
484        if read_length != expected_length
485            || file.metadata().map_err(BoxError::IoError)?.len() != expected_length
486        {
487            return Err(BoxError::BuildError(format!(
488                "guest-init changed while computing cache identity: {}",
489                path.display()
490            )));
491        }
492        Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
493    }
494
495    pub(super) fn create_diff_baseline(&self, layout: &BoxLayout) {
496        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
497        if let Err(error) =
498            crate::rootfs::create_diff_baseline_if_absent(&box_dir, &layout.rootfs_path)
499        {
500            tracing::warn!(
501                box_id = %self.box_id,
502                %error,
503                "Failed to create rootfs diff baseline before workload launch"
504            );
505        }
506    }
507}