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