From 387a8a100ce10b934831c4a4da0ac11a34739e64 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 2 Aug 2026 21:49:49 +0800 Subject: [PATCH 1/4] feat(runtime): materialize guest runtime on Btrfs --- app/arcbox-core/src/agent_client.rs | 12 +- app/arcbox-core/src/boot_assets/config.rs | 11 + app/arcbox-core/src/boot_assets/provider.rs | 101 ++- app/arcbox-core/src/boot_assets/tests.rs | 48 ++ app/arcbox-core/src/config.rs | 19 +- app/arcbox-core/src/runtime.rs | 28 +- app/arcbox-core/src/vm_lifecycle/boot.rs | 43 +- app/arcbox-core/src/vm_lifecycle/mod.rs | 7 +- app/arcbox-core/src/vm_lifecycle/tests.rs | 17 +- app/arcbox-core/src/vm_lifecycle/types.rs | 23 +- app/arcbox-docker/src/handlers/admission.rs | 2 +- common/arcbox-constants/src/cmdline.rs | 8 +- common/arcbox-constants/src/devices.rs | 6 - common/arcbox-constants/src/paths.rs | 12 +- common/arcbox-constants/src/wire.rs | 8 +- docs/boot-assets.md | 5 +- docs/data-directories.md | 14 +- guest/arcbox-agent/src/agent/linux/btrfs.rs | 5 +- guest/arcbox-agent/src/agent/linux/cmdline.rs | 84 +- .../src/agent/linux/kubernetes.rs | 27 +- guest/arcbox-agent/src/agent/linux/mod.rs | 1 + guest/arcbox-agent/src/agent/linux/rpc.rs | 7 + guest/arcbox-agent/src/agent/linux/runtime.rs | 151 +--- .../src/agent/linux/runtime_cache.rs | 173 +++++ guest/arcbox-agent/src/agent/linux/sandbox.rs | 17 + guest/arcbox-agent/src/config.rs | 15 +- guest/arcbox-agent/src/main.rs | 2 + guest/arcbox-agent/src/runtime_materialize.rs | 716 ++++++++++++++++++ tests/fex/README.md | 30 +- tests/fex/validate-fex.sh | 10 +- 30 files changed, 1286 insertions(+), 316 deletions(-) create mode 100644 guest/arcbox-agent/src/agent/linux/runtime_cache.rs create mode 100644 guest/arcbox-agent/src/runtime_materialize.rs diff --git a/app/arcbox-core/src/agent_client.rs b/app/arcbox-core/src/agent_client.rs index 187a0f501..6b52b5911 100644 --- a/app/arcbox-core/src/agent_client.rs +++ b/app/arcbox-core/src/agent_client.rs @@ -341,10 +341,10 @@ impl AgentClient { if resp.protocol_version < MIN_AGENT_PROTOCOL_VERSION { return Err(CoreError::Machine(format!( "guest agent is incompatible with this daemon: agent protocol {} \ - (agent version {}), daemon requires >= {}. The staged agent \ + (agent version {}, response {:?}), daemon requires >= {}. The staged agent \ binary is stale — reinstall or update ArcBox so the bundled \ agent is staged again", - resp.protocol_version, resp.version, MIN_AGENT_PROTOCOL_VERSION, + resp.protocol_version, resp.version, resp.message, MIN_AGENT_PROTOCOL_VERSION, ))); } if resp.protocol_version > AGENT_PROTOCOL_VERSION { @@ -1841,6 +1841,14 @@ mod tests { assert!(err.to_string().contains("incompatible")); } + #[test] + fn previous_protocol_is_rejected() { + let previous = arcbox_constants::wire::AGENT_PROTOCOL_VERSION - 1; + let err = AgentClient::check_agent_protocol(&ping_response(previous)) + .expect_err("previous protocol must be rejected"); + assert!(err.to_string().contains("incompatible")); + } + #[test] fn current_protocol_is_accepted() { let resp = ping_response(arcbox_constants::wire::AGENT_PROTOCOL_VERSION); diff --git a/app/arcbox-core/src/boot_assets/config.rs b/app/arcbox-core/src/boot_assets/config.rs index b865c11ea..d79f99b82 100644 --- a/app/arcbox-core/src/boot_assets/config.rs +++ b/app/arcbox-core/src/boot_assets/config.rs @@ -15,6 +15,8 @@ pub struct BootAssetConfig { pub cache_dir: PathBuf, /// Custom kernel path (skip download). pub custom_kernel: Option, + /// Allow a locally built development manifest to differ from `assets.lock`. + pub allow_unpinned_manifest: bool, } impl Default for BootAssetConfig { @@ -38,6 +40,7 @@ impl Default for BootAssetConfig { .join(".arcbox") .join("boot"), custom_kernel: None, + allow_unpinned_manifest: false, } } } @@ -58,6 +61,14 @@ impl BootAssetConfig { self } + /// Allow local development boot assets whose manifest is generated after + /// the daemon was compiled. + #[must_use] + pub const fn with_unpinned_manifest_allowed(mut self, allowed: bool) -> Self { + self.allow_unpinned_manifest = allowed; + self + } + /// Returns the versioned cache directory (e.g. `~/.arcbox/boot/0.2.0`). #[must_use] pub fn version_cache_dir(&self) -> PathBuf { diff --git a/app/arcbox-core/src/boot_assets/provider.rs b/app/arcbox-core/src/boot_assets/provider.rs index f501627ca..fcd30c067 100644 --- a/app/arcbox-core/src/boot_assets/provider.rs +++ b/app/arcbox-core/src/boot_assets/provider.rs @@ -17,12 +17,6 @@ pub struct BootAssets { pub kernel: PathBuf, /// Path to EROFS rootfs image (attached as /dev/vda, read-only). pub rootfs_image: PathBuf, - /// Path to the read-only EROFS image of the guest container-runtime - /// binaries, when the pinned boot release ships one. Attached as an - /// extra read-only disk so the guest execs the runtime from - /// block-backed storage instead of over VirtioFS (ABX-498); `None` on - /// releases predating it, where the guest keeps using the share. - pub runtime_image: Option, /// Kernel command line. pub cmdline: String, /// Asset version. @@ -104,7 +98,6 @@ impl BootAssetProvider { Ok(BootAssets { kernel: prepared.kernel, rootfs_image: prepared.rootfs, - runtime_image: prepared.runtime_image, cmdline: prepared.kernel_cmdline, version: prepared.version, manifest: prepared.manifest, @@ -152,6 +145,13 @@ impl BootAssetProvider { /// warns — dev workflows drop the pin deliberately to boot locally /// built assets. fn verify_manifest_pin(&self) -> Result<()> { + if self.config.allow_unpinned_manifest { + tracing::warn!( + "development profile allows a locally built boot manifest to bypass the \ + production assets.lock pin" + ); + return Ok(()); + } let Some(expected) = boot_asset_manifest_sha256() else { tracing::warn!( "assets.lock carries no boot manifest_sha256 pin; skipping manifest \ @@ -171,43 +171,13 @@ impl BootAssetProvider { } } - /// Returns true if the current version's boot assets are fully cached - /// (manifest + kernel + rootfs, plus the runtime image on releases that - /// ship one). - /// - /// Callers use this to skip the progress-reported download phase, so a - /// release whose runtime image is still missing must report `false` — - /// otherwise that download happens silently mid-boot instead. + /// Returns true if the current version's boot assets are fully cached. #[must_use] pub fn is_cached(&self) -> bool { let dir = self.config.version_cache_dir(); - if !(dir.join("manifest.json").exists() + dir.join("manifest.json").exists() && dir.join("kernel").exists() - && dir.join("rootfs.erofs").exists()) - { - return false; - } - !self.cached_manifest_ships_runtime_image() || dir.join("runtime.erofs").exists() - } - - /// Whether the cached manifest declares a runtime image for this arch. - /// An unreadable or unparsable manifest answers `false`: `prepare` is the - /// authority and re-fetches it, so guessing `true` here would only force - /// a pointless re-download. - fn cached_manifest_ships_runtime_image(&self) -> bool { - let path = self.config.version_cache_dir().join("manifest.json"); - let Ok(bytes) = std::fs::read(&path) else { - return false; - }; - serde_json::from_slice::(&bytes) - .ok() - .and_then(|manifest| { - manifest - .targets - .get(&self.manager.config().arch) - .map(|target| target.runtime.is_some()) - }) - .unwrap_or(false) + && dir.join("rootfs.erofs").exists() } /// Prefetches boot assets (downloads if not cached). @@ -237,6 +207,20 @@ impl BootAssetProvider { .map_err(|e| CoreError::config(format!("failed to parse manifest: {e}"))) } + pub(crate) fn cached_manifest_has_binary(&self, name: &str) -> Result { + self.verify_manifest_pin()?; + let path = self.cached_manifest_path(); + let bytes = std::fs::read(&path) + .map_err(|e| CoreError::config(format!("failed to read {}: {e}", path.display())))?; + let manifest: BootAssetManifest = serde_json::from_slice(&bytes) + .map_err(|e| CoreError::config(format!("failed to parse {}: {e}", path.display())))?; + Ok(manifest_has_binary( + &manifest, + &self.manager.config().arch, + name, + )) + } + /// Lists all cached version directories. pub async fn list_cached_versions(&self) -> Result> { let cache_dir = &self.config.cache_dir; @@ -299,3 +283,40 @@ impl BootAssetProvider { Ok(()) } } + +fn manifest_has_binary(manifest: &BootAssetManifest, arch: &str, name: &str) -> bool { + manifest + .binaries + .iter() + .any(|binary| binary.name == name && binary.targets.contains_key(arch)) +} + +#[cfg(test)] +mod manifest_tests { + use super::{BootAssetManifest, manifest_has_binary}; + + #[test] + fn binary_capability_is_scoped_to_the_current_architecture() { + let manifest: BootAssetManifest = serde_json::from_value(serde_json::json!({ + "schema_version": 0, + "asset_version": "0.6.13", + "built_at": "now", + "targets": {}, + "binaries": [{ + "name": "FEX", + "version": "1", + "targets": { + "arm64": { + "path": "FEX", + "sha256": "0".repeat(64) + } + } + }] + })) + .unwrap(); + + assert!(manifest_has_binary(&manifest, "arm64", "FEX")); + assert!(!manifest_has_binary(&manifest, "x86_64", "FEX")); + assert!(!manifest_has_binary(&manifest, "arm64", "dockerd")); + } +} diff --git a/app/arcbox-core/src/boot_assets/tests.rs b/app/arcbox-core/src/boot_assets/tests.rs index 388c27913..238447414 100644 --- a/app/arcbox-core/src/boot_assets/tests.rs +++ b/app/arcbox-core/src/boot_assets/tests.rs @@ -76,6 +76,54 @@ fn test_is_cached_requires_all_assets() { assert!(provider.is_cached()); } +#[test] +fn only_development_config_accepts_a_locally_generated_manifest() { + let temp = tempfile::tempdir().unwrap(); + let version = "1.0.0"; + let version_dir = temp.path().join(version); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write( + version_dir.join("manifest.json"), + serde_json::to_vec(&serde_json::json!({ + "schema_version": 1, + "asset_version": version, + "built_at": "now", + "targets": {}, + "binaries": [{ + "name": "FEX", + "version": "1", + "targets": { + "arm64": { + "path": "FEX", + "sha256": "0".repeat(64) + } + } + }] + })) + .unwrap(), + ) + .unwrap(); + + let production = BootAssetProvider::with_config(BootAssetConfig { + version: version.to_string(), + cache_dir: temp.path().to_path_buf(), + arch: "arm64".to_string(), + ..Default::default() + }) + .unwrap(); + assert!(production.cached_manifest_has_binary("FEX").is_err()); + + let development = BootAssetProvider::with_config(BootAssetConfig { + version: version.to_string(), + cache_dir: temp.path().to_path_buf(), + arch: "arm64".to_string(), + allow_unpinned_manifest: true, + ..Default::default() + }) + .unwrap(); + assert!(development.cached_manifest_has_binary("FEX").unwrap()); +} + fn restore_env(original: Option) { // SAFETY: Test code running under ENV_LOCK mutex. unsafe { diff --git a/app/arcbox-core/src/config.rs b/app/arcbox-core/src/config.rs index 67cdd4669..c4abe5991 100644 --- a/app/arcbox-core/src/config.rs +++ b/app/arcbox-core/src/config.rs @@ -48,6 +48,9 @@ use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { + /// Runtime profile selected by the daemon. + #[serde(skip)] + pub profile: ArcboxProfile, /// Data directory. pub data_dir: PathBuf, /// Default VM configuration. @@ -78,6 +81,7 @@ impl Config { pub fn for_profile(profile: ArcboxProfile) -> Self { let layout = HostLayout::for_profile(profile); Self { + profile, data_dir: layout.data_dir, vm: VmDefaults::default(), machine: MachineDefaults::default(), @@ -109,13 +113,15 @@ impl Config { /// Explicit `ARCBOX_*` environment values and config file values override /// profile defaults. pub fn load_for_profile(profile: ArcboxProfile) -> Result> { - Figment::new() + let mut config: Self = Figment::new() .merge(Serialized::defaults(Self::for_profile(profile))) .merge(Toml::file(system_config_path())) .merge(Toml::file(user_config_path())) .merge(Env::prefixed("ARCBOX_").split("_")) .extract() - .map_err(Box::new) + .map_err(Box::new)?; + config.profile = profile; + Ok(config) } /// Loads configuration from a specific file. @@ -409,6 +415,7 @@ mod tests { #[test] fn test_default_config() { let config = Config::default(); + assert_eq!(config.profile, ArcboxProfile::Production); assert_eq!(config.vm.cpus, arcbox_hypervisor::default_vm_cpu_count()); // Default memory is half of host RAM, clamped to [512, 16384] MB. let expected_mb = arcbox_hypervisor::default_vm_memory_size() / (1024 * 1024); @@ -423,6 +430,14 @@ mod tests { ); } + #[test] + fn development_profile_is_preserved_for_runtime_policy() { + assert_eq!( + Config::for_profile(ArcboxProfile::Development).profile, + ArcboxProfile::Development + ); + } + #[test] fn test_effective_cpus_zero_resolves_to_default() { let vm = VmDefaults { diff --git a/app/arcbox-core/src/runtime.rs b/app/arcbox-core/src/runtime.rs index 4a74e6b7f..2ba700a51 100644 --- a/app/arcbox-core/src/runtime.rs +++ b/app/arcbox-core/src/runtime.rs @@ -192,6 +192,8 @@ impl Runtime { ) -> Result { vm_lifecycle_config.guest_docker_vsock_port = Some(config.container.guest_docker_vsock_port); + vm_lifecycle_config.allow_unpinned_boot_manifest = + config.profile == arcbox_constants::paths::ArcboxProfile::Development; let event_bus = EventBus::new(); let snapshot_dir = config.data_dir.join("snapshots"); @@ -513,9 +515,9 @@ impl Runtime { /// is disabled the VZ guest has no x86 `binfmt` handler. /// - **HV** uses FEX, which requires the interpreter provisioned as a /// runtime binary at `/runtime/bin/FEX` (the same `runtime/bin` - /// set as `dockerd`/`containerd`, surfaced to the guest over the `arcbox` - /// VirtioFS share). The guest rootfs init registers the `binfmt_misc` - /// handler iff that binary is present. + /// set as `dockerd`/`containerd`, transported to the guest over the + /// `arcbox` VirtioFS share). The guest agent registers the `binfmt_misc` + /// handler after verifying and materializing FEX onto Btrfs. /// /// Fail-closed (ABX-375): when the active backend's translator is /// unavailable, amd64 requests must return a clear error rather than @@ -527,13 +529,19 @@ impl Runtime { cfg!(all(target_os = "macos", target_arch = "aarch64")) && self.vm_lifecycle.config().default_vm.rosetta } - arcbox_vmm::VmBackend::Hv => self - .config - .data_dir - .join("runtime") - .join("bin") - .join("FEX") - .is_file(), + arcbox_vmm::VmBackend::Hv => { + self.vm_lifecycle + .boot_assets() + .cached_manifest_has_binary("FEX") + .unwrap_or(false) + && self + .config + .data_dir + .join("runtime") + .join("bin") + .join("FEX") + .is_file() + } } } diff --git a/app/arcbox-core/src/vm_lifecycle/boot.rs b/app/arcbox-core/src/vm_lifecycle/boot.rs index 6dfc11d3b..480d471ba 100644 --- a/app/arcbox-core/src/vm_lifecycle/boot.rs +++ b/app/arcbox-core/src/vm_lifecycle/boot.rs @@ -17,9 +17,9 @@ use crate::event::Event; use crate::machine::MachineConfig; use arcbox_constants::cmdline::{ DEBUG_CONSOLE_KEY, DOCKER_METADATA_DEVICE_KEY, GUEST_DOCKER_VSOCK_PORT_KEY, - HV_EARLYCON_DIRECTIVE, RUNTIME_IMAGE_DEVICE_KEY, + HV_EARLYCON_DIRECTIVE, RUNTIME_GENERATION_KEY, }; -use arcbox_constants::devices::{DOCKER_METADATA_BLOCK_DEVICE, RUNTIME_IMAGE_BLOCK_DEVICE}; +use arcbox_constants::devices::DOCKER_METADATA_BLOCK_DEVICE; use arcbox_error::CommonError; use super::actor::{Completion, InternalEvent, LifecycleShared}; @@ -385,19 +385,6 @@ impl LifecycleShared { read_only: false, }); - // Attach the read-only runtime image when the pinned boot release - // ships one: the guest execs dockerd/containerd/the shim/runc from - // this block device instead of over VirtioFS, which costs a FUSE - // round-trip per exec (~7-10x more, measured) on every container - // start. Releases predating it leave this None and the guest keeps - // using the VirtioFS copies (ABX-498). - if let Some(ref runtime_image) = boot.runtime_image { - block_devices.push(crate::vm::BlockDeviceConfig { - path: runtime_image.to_string_lossy().to_string(), - read_only: true, - }); - } - let config = MachineConfig { name: self.machine_name.clone(), cpus: self.config.default_vm.cpus, @@ -488,21 +475,16 @@ impl LifecycleShared { cmdline.push_str(DOCKER_METADATA_BLOCK_DEVICE); } - // Declare the runtime-image device only when this release ships one. - // Its presence is the guest's signal to mount the image and exec the - // runtime from it; its absence means "keep using VirtioFS". Injecting - // it here (rather than at create time) also makes the cmdline drift - // check recreate the machine when a release starts or stops shipping - // the image, which is exactly when the disk set changes. - if assets.runtime_image.is_some() - && !cmdline - .split_whitespace() - .any(|token| token.starts_with(RUNTIME_IMAGE_DEVICE_KEY)) - { - cmdline.push(' '); - cmdline.push_str(RUNTIME_IMAGE_DEVICE_KEY); - cmdline.push_str(RUNTIME_IMAGE_BLOCK_DEVICE); - } + // The pinned boot version names both the manifest and the immutable + // Btrfs runtime generation the guest materializes from it. + cmdline = cmdline + .split_whitespace() + .filter(|token| !token.starts_with(RUNTIME_GENERATION_KEY)) + .collect::>() + .join(" "); + cmdline.push(' '); + cmdline.push_str(RUNTIME_GENERATION_KEY); + cmdline.push_str(&assets.version); // Always attach an interactive debug console on the custom-HV backend. // An operator can `socat - UNIX-CONNECT:` to get a serial root @@ -539,7 +521,6 @@ impl LifecycleShared { kernel: assets.kernel.to_string_lossy().to_string(), cmdline, rootfs_image: assets.rootfs_image, - runtime_image: assets.runtime_image, }) } diff --git a/app/arcbox-core/src/vm_lifecycle/mod.rs b/app/arcbox-core/src/vm_lifecycle/mod.rs index e72731539..acd4f518f 100644 --- a/app/arcbox-core/src/vm_lifecycle/mod.rs +++ b/app/arcbox-core/src/vm_lifecycle/mod.rs @@ -181,8 +181,11 @@ impl VmLifecycleManager { config: VmLifecycleConfig, ) -> Result { let boot_assets = Arc::new( - BootAssetProvider::new(data_dir.join("boot"))? - .with_kernel(config.default_vm.kernel.clone().unwrap_or_default())?, + BootAssetProvider::with_config( + crate::boot_assets::BootAssetConfig::with_cache_dir(data_dir.join("boot")) + .with_unpinned_manifest_allowed(config.allow_unpinned_boot_manifest), + )? + .with_kernel(config.default_vm.kernel.clone().unwrap_or_default())?, ); let health_monitor = Arc::new(HealthMonitor::new( diff --git a/app/arcbox-core/src/vm_lifecycle/tests.rs b/app/arcbox-core/src/vm_lifecycle/tests.rs index 60e51aaf5..54e42be95 100644 --- a/app/arcbox-core/src/vm_lifecycle/tests.rs +++ b/app/arcbox-core/src/vm_lifecycle/tests.rs @@ -189,7 +189,6 @@ fn machine_drift_detects_each_overridable_field() { kernel: "/k".to_string(), cmdline: "console=hvc0 earlycon".to_string(), rootfs_image: std::path::PathBuf::from("/rootfs.erofs"), - runtime_image: None, }; let current = sample_machine(want.cpus, want.memory_mb, &boot.kernel, &boot.cmdline); @@ -224,25 +223,13 @@ fn machine_drift_detects_each_overridable_field() { Some("block_devices") ); - // The runtime image adds a fourth disk, so the expected count follows the - // release: a three-disk machine drifts once the release ships an image, - // and a four-disk machine drifts back once it stops. - let boot_with_image = DesiredBoot { - kernel: boot.kernel.clone(), - cmdline: boot.cmdline.clone(), - rootfs_image: boot.rootfs_image.clone(), - runtime_image: Some(std::path::PathBuf::from("/runtime.erofs")), - }; - assert_eq!( - machine_drift_reason(¤t, &want, &boot_with_image), - Some("block_devices") - ); + // A machine persisted while a fourth runtime disk was attached must be + // recreated now that the runtime lives on the existing Btrfs data disk. let mut m = current; m.block_devices.push(crate::vm::BlockDeviceConfig { path: "/boot/runtime.erofs".to_string(), read_only: true, }); - assert_eq!(machine_drift_reason(&m, &want, &boot_with_image), None); assert_eq!( machine_drift_reason(&m, &want, &boot), Some("block_devices") diff --git a/app/arcbox-core/src/vm_lifecycle/types.rs b/app/arcbox-core/src/vm_lifecycle/types.rs index 95138d856..68078892d 100644 --- a/app/arcbox-core/src/vm_lifecycle/types.rs +++ b/app/arcbox-core/src/vm_lifecycle/types.rs @@ -98,6 +98,8 @@ pub struct VmLifecycleConfig { pub skip_vm_check: bool, /// Guest docker API vsock port propagated via kernel cmdline. pub guest_docker_vsock_port: Option, + /// Allow locally generated boot manifests in the development profile. + pub allow_unpinned_boot_manifest: bool, /// macOS hypervisor backend this lifecycle's machine will use. /// /// Defaults to the System VM default ([`arcbox_vmm::VmBackend::default`], @@ -117,6 +119,7 @@ impl Default for VmLifecycleConfig { default_vm: DefaultVmConfig::default(), skip_vm_check: false, guest_docker_vsock_port: None, + allow_unpinned_boot_manifest: false, backend: arcbox_vmm::VmBackend::default(), } } @@ -164,9 +167,6 @@ pub(super) struct DesiredBoot { pub(super) cmdline: String, /// EROFS rootfs image path (read-only vda). pub(super) rootfs_image: PathBuf, - /// Read-only image of the guest container-runtime binaries, when the - /// pinned boot release ships one (ABX-498). - pub(super) runtime_image: Option, } /// Returns the first daemon-overridable field that differs between a persisted @@ -191,11 +191,11 @@ pub(super) fn machine_drift_reason( Some("kernel") } else if persisted.cmdline.as_deref() != Some(boot.cmdline.as_str()) { Some("cmdline") - } else if persisted.block_devices.len() != boot.expected_disk_count() { - // A machine persisted before a disk was added (the ext4 metadata - // volume, or the runtime image) carries fewer disks; recreating + } else if persisted.block_devices.len() != BASE_MACHINE_DISK_COUNT { + // A machine persisted before or after the current disk layout + // carries a different disk count; recreating // rewrites the machine record — image files are untouched — so the - // guest receives the new device. + // guest receives exactly the current device set. Some("block_devices") } else { None @@ -206,15 +206,6 @@ pub(super) fn machine_drift_reason( /// (vda), btrfs data image (vdb), ext4 metadata image (vdc). const BASE_MACHINE_DISK_COUNT: usize = 3; -impl DesiredBoot { - /// Number of block devices a machine created from these boot params - /// carries: the always-present three, plus the read-only runtime image - /// when the pinned boot release ships one. - pub(super) fn expected_disk_count(&self) -> usize { - BASE_MACHINE_DISK_COUNT + usize::from(self.runtime_image.is_some()) - } -} - /// Derives the metadata-volume image filename paired with a data image: /// `docker.img` → `docker-meta.img`, `docker-rosetta.img` → /// `docker-rosetta-meta.img`. diff --git a/app/arcbox-docker/src/handlers/admission.rs b/app/arcbox-docker/src/handlers/admission.rs index c8fb0f969..03663b57f 100644 --- a/app/arcbox-docker/src/handlers/admission.rs +++ b/app/arcbox-docker/src/handlers/admission.rs @@ -19,7 +19,7 @@ pub async fn require_amd64_runtime( } Err(DockerError::NotImplemented(format!( "linux/amd64 runtime requires FEX in the HV guest, which is not provisioned \ - (expected /arcbox/runtime/bin/FEX). amd64 runtime containers are served by FEX \ + (expected /run/arcbox/runtime/bin/FEX). amd64 runtime containers are served by FEX \ inside the single HV utility VM; ArcBox does not fall back to a VZ/Rosetta runtime \ VM. Requested platform: {:?}.", route.platform, diff --git a/common/arcbox-constants/src/cmdline.rs b/common/arcbox-constants/src/cmdline.rs index 3f710aecc..be2ffe293 100644 --- a/common/arcbox-constants/src/cmdline.rs +++ b/common/arcbox-constants/src/cmdline.rs @@ -7,11 +7,9 @@ pub const DOCKER_DATA_DEVICE_KEY: &str = "arcbox.docker_data_device="; /// Kernel cmdline key for guest Docker metadata block-device path. pub const DOCKER_METADATA_DEVICE_KEY: &str = "arcbox.docker_metadata_device="; -/// Kernel cmdline key for the read-only runtime-image block device. -/// -/// Present only when the host attached the image, so its absence is the -/// guest's signal to keep exec'ing the runtime over VirtioFS. -pub const RUNTIME_IMAGE_DEVICE_KEY: &str = "arcbox.runtime_image_device="; +/// Kernel cmdline key identifying the runtime generation to materialize onto +/// the guest data disk. +pub const RUNTIME_GENERATION_KEY: &str = "arcbox.runtime_generation="; /// Kernel cmdline key carrying the host path of the interactive debug-console /// Unix socket (custom-HV backend). diff --git a/common/arcbox-constants/src/devices.rs b/common/arcbox-constants/src/devices.rs index e608d4af8..7c19ee0a6 100644 --- a/common/arcbox-constants/src/devices.rs +++ b/common/arcbox-constants/src/devices.rs @@ -9,9 +9,3 @@ pub const DOCKER_DATA_BLOCK_DEVICE: &str = "/dev/vdb"; /// The ext4 volume carrying the fsync-hot boltdb metadata directories; paired /// with the btrfs data device (see internal-docs/plans/ext4-metadata-volume.md). pub const DOCKER_METADATA_BLOCK_DEVICE: &str = "/dev/vdc"; - -/// Default read-only runtime-image block device path in guest. -/// -/// The fourth disk, after the rootfs (vda), data (vdb) and metadata (vdc) -/// images; the host declares the real path on the kernel cmdline. -pub const RUNTIME_IMAGE_BLOCK_DEVICE: &str = "/dev/vdd"; diff --git a/common/arcbox-constants/src/paths.rs b/common/arcbox-constants/src/paths.rs index 23011076e..7e4e2b811 100644 --- a/common/arcbox-constants/src/paths.rs +++ b/common/arcbox-constants/src/paths.rs @@ -32,9 +32,15 @@ pub const K3S_CNI_CONF_DIR: &str = "/var/lib/rancher/k3s/agent/etc/cni/net.d"; /// K3s-managed CNI plugin directory used by current releases. pub const K3S_CNI_BIN_DIR: &str = "/var/lib/rancher/k3s/data/cni"; -/// Directory where runtime binaries (containerd, dockerd, runc, …) are -/// accessed via VirtioFS live execution. -pub const ARCBOX_RUNTIME_BIN_DIR: &str = "/arcbox/runtime/bin"; +/// Stable guest-local runtime root. +/// +/// The guest atomically points this symlink at the active generation on the +/// persistent Btrfs data disk. +pub const ARCBOX_RUNTIME_DIR: &str = "/run/arcbox/runtime"; + +/// Directory where guest runtime binaries (containerd, dockerd, runc, …) are +/// executed from the persistent Btrfs data disk. +pub const ARCBOX_RUNTIME_BIN_DIR: &str = "/run/arcbox/runtime/bin"; /// Host-side privileged paths (require root to write). pub mod privileged { diff --git a/common/arcbox-constants/src/wire.rs b/common/arcbox-constants/src/wire.rs index 92cb4e7f2..e611de6dc 100644 --- a/common/arcbox-constants/src/wire.rs +++ b/common/arcbox-constants/src/wire.rs @@ -10,14 +10,18 @@ /// v2: the sandbox execution redesign (CORE-55/56) retired the Run/Exec /// streaming types and re-typed the surviving sandbox payloads (enums, /// timestamps, exit-status oneof). -pub const AGENT_PROTOCOL_VERSION: u32 = 2; +/// +/// v3: runtime startup requires the host-provided +/// `arcbox.runtime_generation` kernel parameter and materializes runtime +/// assets onto the guest Btrfs data disk before execution. +pub const AGENT_PROTOCOL_VERSION: u32 = 3; /// Oldest agent protocol version this host still accepts. /// /// Agents reporting less (including `0` — agents that predate the /// handshake field) are rejected at boot with an actionable error /// instead of silently misbehaving under field skew. -pub const MIN_AGENT_PROTOCOL_VERSION: u32 = 2; +pub const MIN_AGENT_PROTOCOL_VERSION: u32 = 3; /// Number of bytes in the fixed RPC frame header (`length` + `type`). pub const FRAME_HEADER_SIZE: usize = 8; diff --git a/docs/boot-assets.md b/docs/boot-assets.md index 648cdff43..b939c9ebc 100644 --- a/docs/boot-assets.md +++ b/docs/boot-assets.md @@ -13,7 +13,10 @@ Each release contains per-architecture artifacts plus a unified multi-target man - Runtime binaries — dockerd, containerd, containerd-shim-runc-v2, runc, docker-init (from the Docker 29.6.1 static package, shipping containerd 2.2.5) plus k3s, firecracker/jailer, and the microVM vmlinux No initramfs. The kernel boots directly into the EROFS rootfs (`root=/dev/vda ro rootfstype=erofs`). -Agent and runtime binaries are distributed via VirtioFS from the host. +Agent and runtime binaries reach the guest through VirtioFS. Runtime binaries +are checksum-verified against the pinned manifest, copied into a +version-keyed generation on the guest Btrfs data disk, and executed only from +that local generation. ## Responsibilities In This Repository diff --git a/docs/data-directories.md b/docs/data-directories.md index 436cf77b1..74dfc3525 100644 --- a/docs/data-directories.md +++ b/docs/data-directories.md @@ -80,7 +80,9 @@ from `Contents/Resources/assets/{version}/` to avoid a network round-trip. | `runtime/bin/` | dockerd, containerd, runc, etc. | daemon (bundle seed) | Seeded from `Contents/Resources/runtime/` when running inside the app bundle. -Exposed to the guest VM via VirtioFS at `/arcbox/runtime/bin`. +Exposed to the guest VM via VirtioFS at `/arcbox/runtime/` as a transport +source. The agent verifies each manifest entry and materializes it onto the +guest Btrfs data disk before execution. ### 1.6 `bin/` — User Executables @@ -235,7 +237,7 @@ These paths exist inside the Linux VM, not on the macOS host. Defined in |------------|----------------|--------------|---------| | `/arcbox` | `~/.arcbox/` | `arcbox` | ArcBox data sharing | | `/arcbox/log/` | `~/.arcbox/log/` | — | Guest logs visible from host | -| `/arcbox/runtime/bin` | `~/.arcbox/runtime/bin` | — | Runtime binaries | +| `/arcbox/runtime/` | `~/.arcbox/runtime/` | — | Runtime transport source (never executed directly) | | `/Users` | `/Users` | `users` | macOS home directory passthrough | Tags defined in `common/arcbox-constants/src/virtiofs.rs`. @@ -249,13 +251,15 @@ Tags defined in `common/arcbox-constants/src/virtiofs.rs`. | `/var/run/docker.sock` | dockerd API socket | agent (dockerd) | | `/run/containerd/containerd.sock` | containerd gRPC socket | agent (containerd) | | `/run/arcbox/data` | Btrfs temporary mount point | agent | +| `/run/arcbox/data/runtime/{generation}` | Verified runtime generation persisted on Btrfs | agent | +| `/run/arcbox/runtime` | Stable symlink to the active Btrfs runtime generation | agent | | `/run/arcbox/vmm.sock` | Guest VMM gRPC socket | agent | | `/var/lib/arcbox/sandboxes` | Firecracker sandbox data | agent | | `/var/lib/arcbox/sandbox/rootfs.ext4` | Default sandbox rootfs (busybox + vm-agent, auto-built) | agent | | `/var/lib/arcbox/sandbox/rootfs--.ext4` | Converted image rootfs cache, keyed on the source layer and the injected `vm-agent`; superseded entries are swept on the next conversion unless a snapshot still needs them as its dm-snapshot origin | agent | | `/var/lib/arcbox/jailer` | Firecracker jailer chroots | agent | -| `/arcbox/runtime/bin/{firecracker,jailer}` | Firecracker binaries (boot manifest, via VirtioFS) | host daemon | -| `/arcbox/runtime/kernel/vmlinux` | Sandbox guest kernel (boot manifest, via VirtioFS) | host daemon | +| `/run/arcbox/runtime/bin/{firecracker,jailer}` | Firecracker binaries in the active Btrfs runtime generation | agent | +| `/run/arcbox/runtime/kernel/vmlinux` | Sandbox guest kernel in the active Btrfs runtime generation | agent | | `/arcbox/bin/vm-agent` | Sandbox init binary, staged next to `arcbox-agent` (via VirtioFS) | host daemon | | `/etc/arcbox/vmm.toml` | Optional guest VMM config override (not shipped; built-in defaults apply) | admin (manual) | @@ -284,5 +288,5 @@ older installations. They can be safely deleted. | **cli** (`abctl`) | `~/.arcbox/{bin,shell,completions}/`, Docker context, LaunchAgent registration, shell profile injection | | **helper** (root) | `/etc/resolver/`, `/usr/local/bin/` symlinks, `/var/run/docker.sock` symlink | | **desktop** | SMAppService LaunchAgent registration, `~/.arcbox/run/` directory creation, helper install trigger | -| **agent** (guest) | `/arcbox/` mount, `/var/lib/{docker,containerd}/`, guest sockets, Btrfs subvolumes | +| **agent** (guest) | `/arcbox/` transport mount, `/run/arcbox/data/runtime/`, `/var/lib/{docker,containerd}/`, guest sockets, Btrfs subvolumes | | **launchd** | `/var/run/arcbox-helper.sock` (socket-activation), helper service lifecycle | diff --git a/guest/arcbox-agent/src/agent/linux/btrfs.rs b/guest/arcbox-agent/src/agent/linux/btrfs.rs index 150102fb8..13caf6618 100644 --- a/guest/arcbox-agent/src/agent/linux/btrfs.rs +++ b/guest/arcbox-agent/src/agent/linux/btrfs.rs @@ -28,7 +28,7 @@ const BTRFS_TOTAL_BYTES_OFFSET: u64 = 0x10070; /// /// Must live on a writable filesystem. `/run` is tmpfs (set up in PID1 init), /// while EROFS root is read-only and cannot host dynamic mountpoints. -const BTRFS_TEMP_MOUNT: &str = "/run/arcbox/data"; +pub(super) const BTRFS_TEMP_MOUNT: &str = "/run/arcbox/data"; fn has_btrfs_superblock(device: &str) -> bool { let mut file = match std::fs::File::open(device) { @@ -126,7 +126,8 @@ fn ensure_btrfs_format(device: &str) -> Result { /// running containerd/dockerd without persistent storage is unsafe. pub(super) fn ensure_data_mount() -> Result { // Already fully set up? - if crate::mount::is_mounted(DOCKER_DATA_MOUNT_POINT) + if crate::mount::is_mounted(BTRFS_TEMP_MOUNT) + && crate::mount::is_mounted(DOCKER_DATA_MOUNT_POINT) && crate::mount::is_mounted(CONTAINERD_DATA_MOUNT_POINT) && crate::mount::is_mounted(K3S_DATA_MOUNT_POINT) && crate::mount::is_mounted(KUBELET_DATA_MOUNT_POINT) diff --git a/guest/arcbox-agent/src/agent/linux/cmdline.rs b/guest/arcbox-agent/src/agent/linux/cmdline.rs index 13cb99373..135531f70 100644 --- a/guest/arcbox-agent/src/agent/linux/cmdline.rs +++ b/guest/arcbox-agent/src/agent/linux/cmdline.rs @@ -5,7 +5,7 @@ use std::path::Path; use arcbox_constants::cmdline::{ DOCKER_DATA_DEVICE_KEY as DOCKER_DATA_DEVICE_CMDLINE_KEY, DOCKER_METADATA_DEVICE_KEY as DOCKER_METADATA_DEVICE_CMDLINE_KEY, GUEST_DOCKER_VSOCK_PORT_KEY, - RUNTIME_IMAGE_DEVICE_KEY, + RUNTIME_GENERATION_KEY, }; use arcbox_constants::devices::DOCKER_DATA_BLOCK_DEVICE as DOCKER_DATA_DEVICE_DEFAULT; use arcbox_constants::env::GUEST_DOCKER_VSOCK_PORT as GUEST_DOCKER_VSOCK_PORT_ENV; @@ -17,14 +17,13 @@ const HVC_DATA_DEVICE: &str = "/dev/arcboxhvc1"; pub(super) fn cmdline_value(key: &str) -> Option { let cmdline = std::fs::read_to_string("/proc/cmdline").ok()?; - for token in cmdline.split_whitespace() { - if let Some(value) = token.strip_prefix(key) { - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - None + cmdline_value_from(&cmdline, key).map(str::to_owned) +} + +fn cmdline_value_from<'a>(cmdline: &'a str, key: &str) -> Option<&'a str> { + cmdline + .split_whitespace() + .find_map(|token| token.strip_prefix(key).filter(|value| !value.is_empty())) } pub(super) fn docker_api_vsock_port() -> u32 { @@ -68,13 +67,70 @@ pub(super) fn declared_docker_metadata_device() -> Option { cmdline_value(DOCKER_METADATA_DEVICE_CMDLINE_KEY).filter(|v| !v.trim().is_empty()) } -/// The read-only runtime-image device the host declared when it attached the -/// image. `None` means this boot release ships no runtime image, so the guest -/// keeps exec'ing the container runtime over VirtioFS. -pub(super) fn declared_runtime_image_device() -> Option { - cmdline_value(RUNTIME_IMAGE_DEVICE_KEY).filter(|v| !v.trim().is_empty()) +/// The immutable boot-asset generation the guest must materialize locally. +pub(super) fn runtime_generation() -> Result { + let cmdline = std::fs::read_to_string("/proc/cmdline") + .map_err(|error| format!("read kernel cmdline: {error}"))?; + runtime_generation_from(&cmdline) +} + +fn runtime_generation_from(cmdline: &str) -> Result { + let generation = cmdline_value_from(cmdline, RUNTIME_GENERATION_KEY) + .ok_or_else(|| format!("kernel cmdline is missing {RUNTIME_GENERATION_KEY}"))?; + let valid = generation != "." + && generation != ".." + && generation + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + if !valid { + return Err(format!("invalid runtime generation {generation:?}")); + } + Ok(generation.to_string()) +} + +/// Verifies the Btrfs runtime contract for the System VM. +/// +/// Machine VMs share this agent binary but do not carry the System VM's +/// runtime-generation or Docker-metadata device keys. +pub(super) fn validate_runtime_boot_contract() -> Result<(), String> { + let cmdline = std::fs::read_to_string("/proc/cmdline") + .map_err(|error| format!("read kernel cmdline: {error}"))?; + validate_runtime_boot_contract_from(&cmdline) +} + +fn validate_runtime_boot_contract_from(cmdline: &str) -> Result<(), String> { + if cmdline_value_from(cmdline, RUNTIME_GENERATION_KEY).is_some() + || cmdline_value_from(cmdline, DOCKER_METADATA_DEVICE_CMDLINE_KEY).is_some() + { + return runtime_generation_from(cmdline).map(drop); + } + Ok(()) } pub(super) fn kubernetes_api_vsock_port() -> u32 { KUBERNETES_API_VSOCK_PORT } + +#[cfg(test)] +mod tests { + use super::validate_runtime_boot_contract_from; + + #[test] + fn runtime_contract_is_required_only_for_the_system_vm() { + assert!( + validate_runtime_boot_contract_from("root=/dev/vda arcbox.runtime_generation=0.6.13") + .is_ok() + ); + assert!( + validate_runtime_boot_contract_from( + "root=/dev/vda arcbox.docker_metadata_device=/dev/vdc" + ) + .is_err() + ); + assert!( + validate_runtime_boot_contract_from("root=/dev/vda arcbox.machine_rootfs=/dev/vdb") + .is_ok() + ); + assert!(validate_runtime_boot_contract_from("root=/dev/vda custom=1").is_ok()); + } +} diff --git a/guest/arcbox-agent/src/agent/linux/kubernetes.rs b/guest/arcbox-agent/src/agent/linux/kubernetes.rs index beeb521bd..041dcdfb7 100644 --- a/guest/arcbox-agent/src/agent/linux/kubernetes.rs +++ b/guest/arcbox-agent/src/agent/linux/kubernetes.rs @@ -28,13 +28,13 @@ use arcbox_protocol::agent::{ KubernetesStopResponse, }; -use super::btrfs::ensure_data_mount; use super::probe::{probe_first_ready_socket, probe_tcp}; use super::runtime::{ CONTAINERD_SOCKET_CANDIDATES, daemon_log_file, ensure_containerd_ready, ensure_runtime_prerequisites, ensure_shared_runtime_dirs, missing_binaries_at, runtime_path_env, }; +use super::runtime_cache::ensure_local_runtime; use crate::rpc::{ErrorResponse, RpcResponse}; const K3S_PID_FILE: &str = "/run/arcbox/k3s.pid"; @@ -124,6 +124,18 @@ pub(super) async fn handle_kubernetes_kubeconfig(_req: KubernetesKubeconfigReque async fn do_start_kubernetes() -> KubernetesStartResponse { let _guard = kubernetes_control_lock().lock().await; + let mut notes = ensure_runtime_prerequisites(); + match ensure_local_runtime().await { + Ok(note) => notes.push(note), + Err(error) => { + return KubernetesStartResponse { + running: false, + api_ready: false, + endpoint: KUBERNETES_HOST_ENDPOINT.to_string(), + detail: format!("local runtime setup failed: {error}"), + }; + } + } let runtime_bin_dir = PathBuf::from(ARCBOX_RUNTIME_BIN_DIR); let missing = missing_kubernetes_binaries_at(&runtime_bin_dir); if !missing.is_empty() { @@ -138,19 +150,6 @@ async fn do_start_kubernetes() -> KubernetesStartResponse { ), }; } - - let mut notes = ensure_runtime_prerequisites(); - match ensure_data_mount() { - Ok(note) => notes.push(note), - Err(e) => { - return KubernetesStartResponse { - running: false, - api_ready: false, - endpoint: KUBERNETES_HOST_ENDPOINT.to_string(), - detail: format!("data volume setup failed: {e}"), - }; - } - } ensure_shared_runtime_dirs(&mut notes); if !ensure_containerd_ready(&runtime_bin_dir, &mut notes).await { diff --git a/guest/arcbox-agent/src/agent/linux/mod.rs b/guest/arcbox-agent/src/agent/linux/mod.rs index 5913796a0..cfb4f63f2 100644 --- a/guest/arcbox-agent/src/agent/linux/mod.rs +++ b/guest/arcbox-agent/src/agent/linux/mod.rs @@ -16,6 +16,7 @@ mod probe; mod proxy; mod rpc; mod runtime; +mod runtime_cache; mod sandbox; mod stats; mod system_info; diff --git a/guest/arcbox-agent/src/agent/linux/rpc.rs b/guest/arcbox-agent/src/agent/linux/rpc.rs index cc516abe4..14b56c5b5 100644 --- a/guest/arcbox-agent/src/agent/linux/rpc.rs +++ b/guest/arcbox-agent/src/agent/linux/rpc.rs @@ -518,6 +518,13 @@ fn handle_mmap_read_file(req: arcbox_protocol::agent::MmapReadFileRequest) -> Rp /// Handles a Ping request. fn handle_ping(req: arcbox_protocol::agent::PingRequest) -> RpcResponse { tracing::debug!("Ping request: {:?}", req.message); + if let Err(reason) = super::cmdline::validate_runtime_boot_contract() { + return RpcResponse::Ping(PingResponse { + message: format!("incompatible host boot contract: {reason}"), + version: AGENT_VERSION.to_string(), + protocol_version: 0, + }); + } sync_clock_from_host(req.timestamp_secs); RpcResponse::Ping(PingResponse { message: if req.message.is_empty() { diff --git a/guest/arcbox-agent/src/agent/linux/runtime.rs b/guest/arcbox-agent/src/agent/linux/runtime.rs index 102d9905f..b6964cc97 100644 --- a/guest/arcbox-agent/src/agent/linux/runtime.rs +++ b/guest/arcbox-agent/src/agent/linux/runtime.rs @@ -1,7 +1,7 @@ //! Container runtime lifecycle: bring up containerd + dockerd, surface readiness, //! and serve the EnsureRuntime/RuntimeStatus RPC handlers. //! -//! Spawns the bundled `containerd` and `dockerd` from the EROFS rootfs, verifies +//! Materializes the bundled `containerd` and `dockerd` onto Btrfs, verifies //! kernel/filesystem prerequisites (cgroup2, overlayfs, devpts, …), and polls //! socket readiness with both connect-level and HTTP-level probes. @@ -24,9 +24,10 @@ use arcbox_protocol::agent::{ }; use super::btrfs::ensure_data_mount; -use super::cmdline::{declared_runtime_image_device, docker_api_vsock_port}; +use super::cmdline::docker_api_vsock_port; use super::probe::{probe_docker_api_ready, probe_first_ready_socket, probe_unix_socket}; use super::rpc::sync_clock_from_host; +use super::runtime_cache::ensure_local_runtime; use crate::agent::ensure_runtime; use crate::rpc::RpcResponse; @@ -79,7 +80,17 @@ pub(super) async fn handle_ensure_runtime(req: RuntimeEnsureRequest) -> RpcRespo /// Performs the actual runtime start sequence (called only by the driver). async fn do_ensure_runtime_start() -> RuntimeEnsureResponse { let mut notes = Vec::new(); - let note = try_start_bundled_runtime().await; + let note = match try_start_bundled_runtime().await { + Ok(note) => note, + Err(message) => { + return RuntimeEnsureResponse { + ready: false, + endpoint: format!("vsock:{}", docker_api_vsock_port()), + message, + status: ensure_runtime::STATUS_FAILED.to_string(), + }; + } + }; if !note.is_empty() { notes.push(note); } @@ -508,102 +519,6 @@ fn runtime_start_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } -/// Read-only mount point of the block-backed runtime image. -/// -/// The runtime binaries otherwise live on the host-backed `arcbox` VirtioFS -/// share, where every exec costs a FUSE round-trip — measured 7-10x more than -/// exec'ing the same binary from block-backed storage, and paid on *every* -/// container start (so on every `docker build` step). When the boot release -/// ships a runtime image the host attaches it as a read-only disk and the -/// guest execs from here instead (ABX-498). -const RUNTIME_IMAGE_MOUNT: &str = "/run/arcbox/runtime"; - -/// Mounts the read-only runtime image the host declared on the cmdline. -/// Returns whether the mount is available afterward. -/// -/// Best-effort by design: the VirtioFS copies stay as the fallback, so a -/// release without an image, or a failed mount, costs exec speed but never -/// correctness. -fn mount_runtime_image(notes: &mut Vec) -> bool { - if crate::mount::is_mounted(RUNTIME_IMAGE_MOUNT) { - return true; - } - let Some(device) = declared_runtime_image_device() else { - return false; - }; - // The node can lag guest boot; wait briefly rather than silently taking - // the slow path for the rest of this boot. - if !wait_for_runtime_image_device(&device) { - notes.push(format!("runtime image device {device} never appeared")); - return false; - } - if let Err(e) = std::fs::create_dir_all(RUNTIME_IMAGE_MOUNT) { - notes.push(format!("runtime image mkdir failed({e})")); - return false; - } - match std::process::Command::new("/bin/busybox") - .args([ - "mount", - "-t", - "erofs", - "-o", - "ro", - &device, - RUNTIME_IMAGE_MOUNT, - ]) - .status() - { - Ok(status) if status.success() => { - notes.push(format!("mounted runtime image from {device}")); - true - } - _ => { - notes.push(format!( - "runtime image mount failed ({device}); using VirtioFS" - )); - false - } - } -} - -/// Waits up to 5 s for the declared device node (same budget and rationale as -/// the data-device wait in `btrfs.rs`). -fn wait_for_runtime_image_device(device: &str) -> bool { - for attempt in 0..50 { - if Path::new(device).exists() { - if attempt > 0 { - tracing::info!(device, attempt, "waited for runtime image device"); - } - return true; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - false -} - -fn detect_runtime_bin_dir() -> Option { - // Prefer the block-backed runtime image when it is mounted — exec from - // it is 7-10x cheaper than over VirtioFS. Everything downstream (the - // containerd/dockerd spawns and the PATH they inherit) follows this one - // directory, so preferring it here is the whole switch. - let image = PathBuf::from(RUNTIME_IMAGE_MOUNT); - if missing_runtime_binaries_at(&image).is_empty() { - return Some(image); - } - let dir = PathBuf::from(ARCBOX_RUNTIME_BIN_DIR); - if missing_runtime_binaries_at(&dir).is_empty() { - Some(dir) - } else { - None - } -} - -fn runtime_missing_detail() -> String { - let dir = PathBuf::from(ARCBOX_RUNTIME_BIN_DIR); - let missing = missing_runtime_binaries_at(&dir); - runtime_missing_detail_from(&missing) -} - fn runtime_missing_detail_from(missing: &[&'static str]) -> String { if missing.is_empty() { format!("all runtime binaries present under {ARCBOX_RUNTIME_BIN_DIR}") @@ -822,40 +737,36 @@ pub(super) fn daemon_log_file(name: &str) -> Stdio { } } -async fn try_start_bundled_runtime() -> String { +async fn try_start_bundled_runtime() -> Result { let _guard = runtime_start_lock().lock().await; + let mut notes = Vec::new(); + match ensure_local_runtime().await { + Ok(note) => notes.push(note), + Err(error) => return Err(format!("local runtime setup failed: {error}")), + } if probe_unix_socket(DOCKER_API_UNIX_SOCKET).await { - return "docker socket already ready".to_string(); + notes.push("docker socket already ready".to_string()); + return Ok(notes.join("; ")); } - let mut notes = Vec::new(); - - // Ensure kernel/filesystem prerequisites before spawning daemons — and - // before mounting the runtime image below, since that mounts under /run - // and the prerequisites are what guarantee /run is a writable tmpfs. + // Ensure kernel/filesystem prerequisites before spawning daemons. let prereq_notes = ensure_runtime_prerequisites(); if !prereq_notes.is_empty() { tracing::info!(prerequisites = %prereq_notes.join("; "), "runtime prerequisites"); } notes.extend(prereq_notes); - // Mount the runtime image (when this release ships one) before probing - // for the runtime binaries, so the probe can prefer it. - mount_runtime_image(&mut notes); - - let Some(runtime_bin_dir) = detect_runtime_bin_dir() else { - return runtime_missing_detail(); - }; + let runtime_bin_dir = PathBuf::from(ARCBOX_RUNTIME_BIN_DIR); + let missing = missing_runtime_binaries_at(&runtime_bin_dir); + if !missing.is_empty() { + return Err(runtime_missing_detail_from(&missing)); + } tracing::info!( runtime_bin_dir = %runtime_bin_dir.display(), "starting bundled runtime" ); - match ensure_data_mount() { - Ok(note) => notes.push(note), - Err(e) => return format!("data volume setup failed: {}", e), - } // Bind the fsync-hot metadata dirs onto the ext4 volume before the // daemons open their boltdb files. A hard error means the volume exists @@ -863,18 +774,18 @@ async fn try_start_bundled_runtime() -> String { // state would fork it, so abort instead. match super::metadata_volume::ensure_metadata_mount() { Ok(note) => notes.push(note), - Err(e) => return format!("metadata volume setup failed: {}", e), + Err(e) => return Err(format!("metadata volume setup failed: {e}")), } ensure_shared_runtime_dirs(&mut notes); if !ensure_containerd_ready(&runtime_bin_dir, &mut notes).await { - return notes.join("; "); + return Err(notes.join("; ")); } ensure_dockerd_ready(&runtime_bin_dir, &mut notes).await; - notes.join("; ") + Ok(notes.join("; ")) } /// Brings up the read-only NFS export of the docker data mount so the host diff --git a/guest/arcbox-agent/src/agent/linux/runtime_cache.rs b/guest/arcbox-agent/src/agent/linux/runtime_cache.rs new file mode 100644 index 000000000..7270125d1 --- /dev/null +++ b/guest/arcbox-agent/src/agent/linux/runtime_cache.rs @@ -0,0 +1,173 @@ +//! Guest-local runtime materialization and architecture translator setup. + +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +use anyhow::{Context as _, Result, bail}; + +use arcbox_constants::paths::{ARCBOX_RUNTIME_BIN_DIR, ARCBOX_RUNTIME_DIR}; + +use super::btrfs::{BTRFS_TEMP_MOUNT, ensure_data_mount}; +use super::cmdline::runtime_generation; +use crate::runtime_materialize::{MaterializeRequest, materialize_runtime}; + +const RUNTIME_SOURCE_ROOT: &str = "/arcbox/runtime"; +const BOOT_SOURCE_ROOT: &str = "/arcbox/boot"; +const FEX_BINFMT_ENTRY: &str = "/proc/sys/fs/binfmt_misc/FEX-x86_64"; +const ROSETTA_BINFMT_ENTRY: &str = "/proc/sys/fs/binfmt_misc/rosetta"; +const BINFMT_REGISTER: &str = "/proc/sys/fs/binfmt_misc/register"; + +fn runtime_state() -> &'static Mutex> { + static STATE: OnceLock>> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(None)) +} + +/// Ensures the Btrfs data disk contains the active runtime generation. +pub(super) async fn ensure_local_runtime() -> Result { + tokio::task::spawn_blocking(ensure_local_runtime_blocking) + .await + .map_err(|error| format!("runtime materialization task failed: {error}"))? + .map_err(|error| format!("{error:#}")) +} + +fn ensure_local_runtime_blocking() -> Result { + let mut state = runtime_state() + .lock() + .map_err(|_| anyhow::anyhow!("runtime materialization lock poisoned"))?; + if let Some(note) = state.as_ref() { + return Ok(note.clone()); + } + + unregister_fex()?; + let data_note = ensure_data_mount().map_err(anyhow::Error::msg)?; + let generation = runtime_generation().map_err(anyhow::Error::msg)?; + let arch = manifest_arch()?; + let manifest_path = Path::new(BOOT_SOURCE_ROOT) + .join(&generation) + .join("manifest.json"); + + let runtime = materialize_runtime(&MaterializeRequest { + manifest_path: &manifest_path, + source_root: Path::new(RUNTIME_SOURCE_ROOT), + data_root: Path::new(BTRFS_TEMP_MOUNT), + stable_root: Path::new(ARCBOX_RUNTIME_DIR), + generation: &generation, + arch, + })?; + let fex_note = register_local_fex(Path::new(ARCBOX_RUNTIME_BIN_DIR))?; + let action = if runtime.reused { + "reused" + } else { + "materialized" + }; + + tracing::info!( + generation, + arch, + runtime_root = %runtime.root.display(), + asset_count = runtime.asset_count, + action, + "guest-local runtime ready" + ); + let note = format!( + "{data_note}; {action} runtime generation {generation} ({} assets); {fex_note}", + runtime.asset_count + ); + *state = Some(note.clone()); + Ok(note) +} + +fn manifest_arch() -> Result<&'static str> { + match std::env::consts::ARCH { + "aarch64" => Ok("arm64"), + "x86_64" => Ok("x86_64"), + arch => bail!("unsupported runtime architecture {arch}"), + } +} + +fn register_local_fex(runtime_bin_dir: &Path) -> Result { + if cfg!(not(target_arch = "aarch64")) { + return Ok("FEX not required on this architecture".to_string()); + } + + let fex = runtime_bin_dir.join("FEX"); + if Path::new(ROSETTA_BINFMT_ENTRY).exists() { + return Ok("Rosetta binfmt already active; FEX registration skipped".to_string()); + } + + if !fex.exists() { + return Ok("FEX not present in this boot manifest".to_string()); + } + + ensure_binfmt_mounted()?; + let link = std::process::Command::new("/bin/busybox") + .args(["ln", "-snf", "/proc/self/fd", "/dev/fd"]) + .status() + .context("create /dev/fd for FEX")?; + if !link.success() { + bail!( + "create /dev/fd for FEX failed (exit={})", + link.code().unwrap_or(-1) + ); + } + + let registration = format!( + ":FEX-x86_64:M:0:\\x7fELF\\x02\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x3e\\x00:\\xff\\xff\\xff\\xff\\xff\\xfe\\xfe\\x00\\x00\\x00\\x00\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff:{}:POCF", + fex.display() + ); + std::fs::write(BINFMT_REGISTER, registration.as_bytes()) + .context("register Btrfs-backed FEX interpreter")?; + if !Path::new(FEX_BINFMT_ENTRY).exists() { + bail!("FEX binfmt registration did not create {FEX_BINFMT_ENTRY}"); + } + + Ok(format!("registered FEX from {}", fex.display())) +} + +fn unregister_fex() -> Result<()> { + if cfg!(target_arch = "aarch64") && Path::new(FEX_BINFMT_ENTRY).exists() { + // `F` pins the interpreter inode, so remove the legacy VirtioFS entry + // before materialization can fail. + std::fs::write(FEX_BINFMT_ENTRY, b"-1\n") + .context("remove stale FEX binfmt registration")?; + } + Ok(()) +} + +fn ensure_binfmt_mounted() -> Result<()> { + if Path::new(BINFMT_REGISTER).exists() { + return Ok(()); + } + std::fs::create_dir_all("/proc/sys/fs/binfmt_misc") + .context("create binfmt_misc mount point")?; + let status = std::process::Command::new("/bin/busybox") + .args([ + "mount", + "-t", + "binfmt_misc", + "binfmt_misc", + "/proc/sys/fs/binfmt_misc", + ]) + .status() + .context("mount binfmt_misc")?; + if !status.success() { + bail!( + "mount binfmt_misc failed (exit={})", + status.code().unwrap_or(-1) + ); + } + if !Path::new(BINFMT_REGISTER).exists() { + bail!("binfmt_misc mounted without register endpoint"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::manifest_arch; + + #[test] + fn current_arch_has_manifest_name() { + assert!(matches!(manifest_arch(), Ok("arm64" | "x86_64"))); + } +} diff --git a/guest/arcbox-agent/src/agent/linux/sandbox.rs b/guest/arcbox-agent/src/agent/linux/sandbox.rs index ffaafaa24..d8cfcd0ec 100644 --- a/guest/arcbox-agent/src/agent/linux/sandbox.rs +++ b/guest/arcbox-agent/src/agent/linux/sandbox.rs @@ -12,6 +12,7 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::Mutex; use super::port_forward::{PortForwardManager, Protocol}; +use super::runtime_cache::ensure_local_runtime; use crate::rpc::{ErrorResponse, MessageType, write_message}; use crate::sandbox::SandboxService; @@ -114,6 +115,22 @@ pub(super) async fn handle_sandbox_message( where S: AsyncRead + AsyncWrite + Unpin, { + if matches!( + msg_type, + MessageType::SandboxCreateRequest | MessageType::SandboxRestoreRequest + ) { + if let Err(reason) = ensure_local_runtime().await { + send_sandbox_error( + stream, + trace_id, + 503, + &format!("sandbox runtime unavailable: {reason}"), + ) + .await?; + return Ok(()); + } + } + let svc = match sandbox_service() { Ok(s) => Arc::clone(s), Err((code, reason)) => { diff --git a/guest/arcbox-agent/src/config.rs b/guest/arcbox-agent/src/config.rs index fa4bdc15e..e51a08364 100644 --- a/guest/arcbox-agent/src/config.rs +++ b/guest/arcbox-agent/src/config.rs @@ -7,6 +7,7 @@ //! 2. `/etc/arcbox/vmm.toml` //! 3. Built-in guest defaults +use arcbox_constants::paths::{ARCBOX_RUNTIME_BIN_DIR, ARCBOX_RUNTIME_DIR}; use arcbox_vm::VmmConfig; use arcbox_vm::config::{DefaultVmConfig, FirecrackerConfig, GrpcConfig, NetworkConfig}; @@ -15,19 +16,19 @@ use arcbox_vm::config::{DefaultVmConfig, FirecrackerConfig, GrpcConfig, NetworkC /// These differ from [`VmmConfig::default()`] which targets the host-side /// daemon. Paths follow the guest view of host assets: /// -/// - Boot-manifest binaries (firecracker, jailer) download to -/// `~/.arcbox/runtime/bin` and vmlinux to `~/.arcbox/runtime/kernel` -/// (`install_dir = "kernel"`); the data dir is VirtioFS-mounted at -/// `/arcbox`, so the guest sees `/arcbox/runtime/{bin,kernel}`. +/// - Boot-manifest binaries are materialized onto the guest Btrfs data disk +/// before the sandbox service can use them. /// - The default sandbox rootfs is auto-built by the agent (busybox + /// vm-agent, see `rootfs_builder::ensure_default_rootfs`) on the writable /// btrfs data volume. fn guest_defaults() -> VmmConfig { + let runtime_bin = std::path::Path::new(ARCBOX_RUNTIME_BIN_DIR); + let runtime_root = std::path::Path::new(ARCBOX_RUNTIME_DIR); VmmConfig { firecracker: FirecrackerConfig { - binary: "/arcbox/runtime/bin/firecracker".into(), + binary: runtime_bin.join("firecracker").to_string_lossy().into(), jailer: Some(arcbox_vm::config::JailerConfig { - binary: "/arcbox/runtime/bin/jailer".into(), + binary: runtime_bin.join("jailer").to_string_lossy().into(), uid: 0, gid: 0, chroot_base_dir: Some("/var/lib/arcbox/jailer".into()), @@ -57,7 +58,7 @@ fn guest_defaults() -> VmmConfig { defaults: DefaultVmConfig { vcpus: 1, memory_mib: 512, - kernel: "/arcbox/runtime/kernel/vmlinux".into(), + kernel: runtime_root.join("kernel/vmlinux").to_string_lossy().into(), rootfs: "/var/lib/arcbox/sandbox/rootfs.ext4".into(), boot_args: "console=ttyS0 reboot=k panic=1 pci=off init=/sbin/vm-agent".into(), }, diff --git a/guest/arcbox-agent/src/main.rs b/guest/arcbox-agent/src/main.rs index 456ce1741..0fd70a313 100644 --- a/guest/arcbox-agent/src/main.rs +++ b/guest/arcbox-agent/src/main.rs @@ -12,6 +12,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod agent; mod init; +#[cfg(any(target_os = "linux", test))] +pub(crate) mod runtime_materialize; mod supervisor; // Consumed by the Linux agent's WatchMemoryPressure handler; the library diff --git a/guest/arcbox-agent/src/runtime_materialize.rs b/guest/arcbox-agent/src/runtime_materialize.rs new file mode 100644 index 000000000..082bd3256 --- /dev/null +++ b/guest/arcbox-agent/src/runtime_materialize.rs @@ -0,0 +1,716 @@ +//! Atomic materialization of guest runtime assets onto persistent storage. + +#![allow( + clippy::redundant_pub_crate, + reason = "the materializer is shared inside the crate but is not public API" +)] + +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context as _, Result, bail}; +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; + +const COMPLETE_MARKER: &str = ".manifest.sha256"; +const COPY_BUFFER_SIZE: usize = 256 * 1024; + +/// Inputs for materializing one immutable runtime generation. +pub(crate) struct MaterializeRequest<'a> { + /// Verified boot manifest naming the source assets. + pub manifest_path: &'a Path, + /// VirtioFS source root containing `bin/` and `kernel/`. + pub source_root: &'a Path, + /// Raw Btrfs mount under which generations are stored. + pub data_root: &'a Path, + /// Stable symlink consumers execute through. + pub stable_root: &'a Path, + /// Immutable boot-asset generation. + pub generation: &'a str, + /// Manifest architecture key (`arm64` or `x86_64`). + pub arch: &'a str, +} + +/// Published local runtime generation. +#[derive(Debug)] +pub(crate) struct MaterializedRuntime { + /// Generation-specific Btrfs directory. + pub root: PathBuf, + /// Whether an already-complete generation was reused. + pub reused: bool, + /// Number of current-architecture assets in the generation. + pub asset_count: usize, +} + +#[derive(Deserialize)] +struct RuntimeManifest { + asset_version: String, + #[serde(default)] + binaries: Vec, +} + +#[derive(Deserialize)] +struct ManifestBinary { + name: String, + targets: std::collections::BTreeMap, + install_dir: Option, +} + +#[derive(Deserialize)] +struct ManifestTarget { + sha256: String, +} + +struct PlannedAsset { + source: PathBuf, + destination: PathBuf, + sha256: String, +} + +/// Copies the manifest's current-architecture assets to Btrfs and atomically +/// publishes the completed generation through `stable_root`. +pub(crate) fn materialize_runtime(request: &MaterializeRequest<'_>) -> Result { + validate_generation(request.generation)?; + + let manifest_bytes = fs::read(request.manifest_path) + .with_context(|| format!("read runtime manifest {}", request.manifest_path.display()))?; + let manifest: RuntimeManifest = serde_json::from_slice(&manifest_bytes) + .with_context(|| format!("parse runtime manifest {}", request.manifest_path.display()))?; + if manifest.asset_version != request.generation { + bail!( + "runtime manifest version {} does not match generation {}", + manifest.asset_version, + request.generation + ); + } + + let manifest_sha256 = format!("{:x}", Sha256::digest(&manifest_bytes)); + let assets = plan_assets(&manifest, request.source_root, request.arch)?; + let generations_root = request.data_root.join("runtime"); + let generation_root = generations_root.join(request.generation); + + fs::create_dir_all(&generations_root) + .with_context(|| format!("create runtime cache {}", generations_root.display()))?; + sync_directory(request.data_root)?; + + if generation_complete(&generation_root, &manifest_sha256, &assets) { + publish_stable_link(request.stable_root, &generation_root)?; + remove_old_generations(&generations_root, &generation_root)?; + return Ok(MaterializedRuntime { + root: generation_root, + reused: true, + asset_count: assets.len(), + }); + } + + let temporary_root = create_staging_directory(&generations_root, request.generation)?; + + let staged = stage_generation(&temporary_root, &assets, &manifest_sha256); + if let Err(error) = staged { + let _ = remove_path(&temporary_root); + return Err(error); + } + + if let Err(error) = publish_generation(&temporary_root, &generation_root) { + let _ = remove_path(&temporary_root); + return Err(error); + } + sync_directory(&generations_root)?; + publish_stable_link(request.stable_root, &generation_root)?; + remove_old_generations(&generations_root, &generation_root)?; + + Ok(MaterializedRuntime { + root: generation_root, + reused: false, + asset_count: assets.len(), + }) +} + +fn plan_assets( + manifest: &RuntimeManifest, + source_root: &Path, + arch: &str, +) -> Result> { + let mut destinations = HashSet::new(); + let mut assets = Vec::new(); + + for binary in &manifest.binaries { + let Some(target) = binary.targets.get(arch) else { + continue; + }; + validate_path_component(&binary.name, "runtime asset name")?; + validate_sha256(&target.sha256, &binary.name)?; + + let install_dir = binary.install_dir.as_deref().unwrap_or("bin"); + validate_relative_path(install_dir, "runtime install directory")?; + let relative = Path::new(install_dir).join(&binary.name); + if !destinations.insert(relative.clone()) { + bail!("duplicate runtime asset destination {}", relative.display()); + } + assets.push(PlannedAsset { + source: source_root.join(&relative), + destination: relative, + sha256: target.sha256.to_ascii_lowercase(), + }); + } + + if assets.is_empty() { + bail!("runtime manifest has no assets for architecture {arch}"); + } + Ok(assets) +} + +fn create_staging_directory(generations_root: &Path, generation: &str) -> Result { + static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(0); + + loop { + let staging_id = NEXT_STAGING_ID.fetch_add(1, Ordering::Relaxed); + let path = generations_root.join(format!( + ".{generation}.{}.{}.tmp", + std::process::id(), + staging_id + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error) + .with_context(|| format!("create runtime staging {}", path.display())); + } + } + } +} + +fn stage_generation( + temporary_root: &Path, + assets: &[PlannedAsset], + manifest_sha256: &str, +) -> Result<()> { + let mut asset_directories = HashSet::new(); + for asset in assets { + let destination = temporary_root.join(&asset.destination); + let parent = destination + .parent() + .context("runtime asset destination has no parent")?; + fs::create_dir_all(parent) + .with_context(|| format!("create runtime asset directory {}", parent.display()))?; + let mut directory = parent; + while directory != temporary_root { + asset_directories.insert(directory.to_path_buf()); + directory = directory + .parent() + .context("runtime asset directory escaped staging root")?; + } + copy_verified(&asset.source, &destination, &asset.sha256)?; + } + let mut asset_directories = asset_directories.into_iter().collect::>(); + asset_directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + for directory in asset_directories { + sync_directory(&directory)?; + } + + let marker = temporary_root.join(COMPLETE_MARKER); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker) + .with_context(|| format!("create runtime marker {}", marker.display()))?; + writeln!(file, "{manifest_sha256}")?; + file.sync_all() + .with_context(|| format!("sync runtime marker {}", marker.display()))?; + sync_directory(temporary_root) +} + +fn copy_verified(source: &Path, destination: &Path, expected_sha256: &str) -> Result<()> { + let metadata = fs::symlink_metadata(source) + .with_context(|| format!("inspect runtime source {}", source.display()))?; + if !metadata.file_type().is_file() { + bail!("runtime source is not a regular file: {}", source.display()); + } + if metadata.permissions().mode() & 0o111 == 0 { + bail!("runtime source is not executable: {}", source.display()); + } + + let mut input = + File::open(source).with_context(|| format!("open runtime source {}", source.display()))?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .with_context(|| format!("create runtime asset {}", destination.display()))?; + let mut digest = Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + + loop { + let read = input + .read(&mut buffer) + .with_context(|| format!("read runtime source {}", source.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + output + .write_all(&buffer[..read]) + .with_context(|| format!("write runtime asset {}", destination.display()))?; + } + + let actual_sha256 = format!("{:x}", digest.finalize()); + if actual_sha256 != expected_sha256 { + bail!( + "runtime asset SHA-256 mismatch for {}: expected {}, got {}", + source.display(), + expected_sha256, + actual_sha256 + ); + } + + output + .set_permissions(metadata.permissions()) + .with_context(|| format!("set runtime permissions {}", destination.display()))?; + output + .sync_all() + .with_context(|| format!("sync runtime asset {}", destination.display())) +} + +fn generation_complete( + generation_root: &Path, + manifest_sha256: &str, + assets: &[PlannedAsset], +) -> bool { + let marker_matches = fs::read_to_string(generation_root.join(COMPLETE_MARKER)) + .is_ok_and(|marker| marker.trim() == manifest_sha256); + marker_matches + && assets.iter().all(|asset| { + let path = generation_root.join(&asset.destination); + fs::symlink_metadata(&path).is_ok_and(|metadata| { + metadata.file_type().is_file() + && metadata.permissions().mode() & 0o111 != 0 + && sha256_file(&path).is_ok_and(|digest| digest == asset.sha256) + }) + }) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = + File::open(path).with_context(|| format!("open runtime asset {}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("read runtime asset {}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn publish_generation(temporary_root: &Path, generation_root: &Path) -> Result<()> { + match fs::symlink_metadata(generation_root) { + Ok(_) => replace_generation(temporary_root, generation_root), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::rename(temporary_root, generation_root).with_context(|| { + format!( + "publish runtime generation {} -> {}", + temporary_root.display(), + generation_root.display() + ) + }) + } + Err(error) => Err(error).with_context(|| { + format!( + "inspect runtime generation before publish {}", + generation_root.display() + ) + }), + } +} + +#[cfg(target_os = "linux")] +fn replace_generation(temporary_root: &Path, generation_root: &Path) -> Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt as _; + + let temporary = CString::new(temporary_root.as_os_str().as_bytes()) + .context("runtime staging path contains NUL")?; + let generation = CString::new(generation_root.as_os_str().as_bytes()) + .context("runtime generation path contains NUL")?; + // SAFETY: both C strings are NUL-terminated and remain alive for the + // syscall. `SYS_renameat2(RENAME_EXCHANGE)` atomically swaps two existing + // paths on the same Btrfs mount; the kernel does not retain either pointer. + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + temporary.as_ptr(), + libc::AT_FDCWD, + generation.as_ptr(), + libc::RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "atomically replace runtime generation {} with {}", + generation_root.display(), + temporary_root.display() + ) + }) + } +} + +#[cfg(not(target_os = "linux"))] +fn replace_generation(temporary_root: &Path, generation_root: &Path) -> Result<()> { + let retired_root = temporary_root.with_extension("old"); + fs::rename(generation_root, &retired_root).with_context(|| { + format!( + "retire runtime generation {} -> {}", + generation_root.display(), + retired_root.display() + ) + })?; + if let Err(publish_error) = fs::rename(temporary_root, generation_root) { + if let Err(restore_error) = fs::rename(&retired_root, generation_root) { + bail!( + "publish replacement runtime generation failed: {publish_error}; \ + restoring previous generation also failed: {restore_error}" + ); + } + return Err(publish_error).context("publish replacement runtime generation"); + } + remove_path(&retired_root) +} + +fn publish_stable_link(stable_root: &Path, generation_root: &Path) -> Result<()> { + let parent = stable_root + .parent() + .context("stable runtime path has no parent")?; + fs::create_dir_all(parent) + .with_context(|| format!("create stable runtime parent {}", parent.display()))?; + + if let Ok(metadata) = fs::symlink_metadata(stable_root) { + if !metadata.file_type().is_symlink() { + bail!( + "stable runtime path is not a symlink: {}", + stable_root.display() + ); + } + if fs::read_link(stable_root).is_ok_and(|target| target == generation_root) { + return Ok(()); + } + } + + let temporary_link = parent.join(format!(".runtime-link.{}.tmp", std::process::id())); + if fs::symlink_metadata(&temporary_link).is_ok() { + fs::remove_file(&temporary_link) + .with_context(|| format!("remove stale runtime link {}", temporary_link.display()))?; + } + symlink(generation_root, &temporary_link).with_context(|| { + format!( + "create runtime link {} -> {}", + temporary_link.display(), + generation_root.display() + ) + })?; + fs::rename(&temporary_link, stable_root).with_context(|| { + format!( + "publish runtime link {} -> {}", + stable_root.display(), + generation_root.display() + ) + })?; + sync_directory(parent) +} + +fn remove_old_generations(generations_root: &Path, current: &Path) -> Result<()> { + for entry in fs::read_dir(generations_root) + .with_context(|| format!("list runtime generations {}", generations_root.display()))? + { + let entry = entry?; + let path = entry.path(); + if path == current { + continue; + } + remove_path(&path) + .with_context(|| format!("remove old runtime generation {}", path.display()))?; + } + sync_directory(generations_root) +} + +fn remove_path(path: &Path) -> Result<()> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error).with_context(|| format!("inspect runtime path {}", path.display())); + } + }; + if metadata.file_type().is_dir() { + fs::remove_dir_all(path) + .with_context(|| format!("remove runtime directory {}", path.display())) + } else { + fs::remove_file(path).with_context(|| format!("remove runtime path {}", path.display())) + } +} + +fn validate_generation(value: &str) -> Result<()> { + let valid = value != "." + && value != ".." + && !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + if !valid { + bail!("runtime generation is invalid: {value:?}"); + } + Ok(()) +} + +fn validate_path_component(value: &str, label: &str) -> Result<()> { + let mut components = Path::new(value).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + bail!("{label} must be one relative path component: {value:?}"); + } + Ok(()) +} + +fn validate_relative_path(value: &str, label: &str) -> Result<()> { + if value.is_empty() + || Path::new(value) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + bail!("{label} must be a safe relative path: {value:?}"); + } + Ok(()) +} + +fn validate_sha256(value: &str, name: &str) -> Result<()> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("runtime asset {name} has an invalid SHA-256 digest"); + } + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<()> { + File::open(path) + .with_context(|| format!("open directory for sync {}", path.display()))? + .sync_all() + .with_context(|| format!("sync directory {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn write_executable(path: &Path, bytes: &[u8]) -> String { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + format!("{:x}", Sha256::digest(bytes)) + } + + fn write_manifest(path: &Path, generation: &str, dockerd_sha: &str, kernel_sha: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let manifest = json!({ + "asset_version": generation, + "binaries": [ + { + "name": "dockerd", + "version": "1", + "targets": { "arm64": { "path": "unused", "sha256": dockerd_sha } } + }, + { + "name": "vmlinux", + "version": "1", + "install_dir": "kernel", + "targets": { "arm64": { "path": "unused", "sha256": kernel_sha } } + }, + { + "name": "x86-only", + "version": "1", + "targets": { + "x86_64": { + "path": "unused", + "sha256": "0".repeat(64) + } + } + } + ] + }); + fs::write(path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + } + + #[test] + fn materializes_only_current_arch_and_atomically_switches_generation() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let data = temp.path().join("data"); + let stable = temp.path().join("run/runtime"); + let dockerd_sha = write_executable(&source.join("bin/dockerd"), b"dockerd"); + let kernel_sha = write_executable(&source.join("kernel/vmlinux"), b"kernel"); + + for generation in ["0.6.13", "0.6.14"] { + let manifest = temp.path().join(format!("boot/{generation}/manifest.json")); + write_manifest(&manifest, generation, &dockerd_sha, &kernel_sha); + let result = materialize_runtime(&MaterializeRequest { + manifest_path: &manifest, + source_root: &source, + data_root: &data, + stable_root: &stable, + generation, + arch: "arm64", + }) + .unwrap(); + + assert_eq!(result.asset_count, 2); + assert!(result.root.join("bin/dockerd").is_file()); + assert!(result.root.join("kernel/vmlinux").is_file()); + assert!(!result.root.join("bin/x86-only").exists()); + assert_eq!(fs::read_link(&stable).unwrap(), result.root); + if generation == "0.6.13" { + fs::create_dir_all(data.join("runtime/.crashed.tmp")).unwrap(); + } + if generation == "0.6.14" { + assert!(!data.join("runtime/0.6.13").exists()); + assert!(!data.join("runtime/.crashed.tmp").exists()); + } + } + let entries = fs::read_dir(data.join("runtime")) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(entries, [std::ffi::OsString::from("0.6.14")]); + } + + #[test] + fn reuses_complete_generation_after_sources_are_removed() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let data = temp.path().join("data"); + let stable = temp.path().join("run/runtime"); + let dockerd_sha = write_executable(&source.join("bin/dockerd"), b"dockerd"); + let kernel_sha = write_executable(&source.join("kernel/vmlinux"), b"kernel"); + let manifest = temp.path().join("boot/0.6.13/manifest.json"); + write_manifest(&manifest, "0.6.13", &dockerd_sha, &kernel_sha); + let request = MaterializeRequest { + manifest_path: &manifest, + source_root: &source, + data_root: &data, + stable_root: &stable, + generation: "0.6.13", + arch: "arm64", + }; + + assert!(!materialize_runtime(&request).unwrap().reused); + fs::remove_dir_all(&source).unwrap(); + let reused = materialize_runtime(&request).unwrap(); + + assert!(reused.reused); + assert!(reused.root.join("bin/dockerd").is_file()); + assert_eq!(fs::read_link(&stable).unwrap(), reused.root); + } + + #[test] + fn repairs_a_locally_tampered_generation_from_verified_sources() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let data = temp.path().join("data"); + let stable = temp.path().join("run/runtime"); + let dockerd_sha = write_executable(&source.join("bin/dockerd"), b"dockerd"); + let kernel_sha = write_executable(&source.join("kernel/vmlinux"), b"kernel"); + let manifest = temp.path().join("boot/0.6.13/manifest.json"); + write_manifest(&manifest, "0.6.13", &dockerd_sha, &kernel_sha); + let request = MaterializeRequest { + manifest_path: &manifest, + source_root: &source, + data_root: &data, + stable_root: &stable, + generation: "0.6.13", + arch: "arm64", + }; + + let initial = materialize_runtime(&request).unwrap(); + fs::write(initial.root.join("bin/dockerd"), b"locally tampered").unwrap(); + let repaired = materialize_runtime(&request).unwrap(); + + assert!(!repaired.reused); + assert_eq!( + fs::read(repaired.root.join("bin/dockerd")).unwrap(), + b"dockerd" + ); + assert_eq!(fs::read_link(&stable).unwrap(), repaired.root); + } + + #[test] + fn failed_same_generation_replacement_preserves_previous_runtime() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let data = temp.path().join("data"); + let stable = temp.path().join("run/runtime"); + let dockerd_sha = write_executable(&source.join("bin/dockerd"), b"dockerd-v1"); + let kernel_sha = write_executable(&source.join("kernel/vmlinux"), b"kernel"); + let manifest = temp.path().join("boot/0.6.13/manifest.json"); + write_manifest(&manifest, "0.6.13", &dockerd_sha, &kernel_sha); + let request = MaterializeRequest { + manifest_path: &manifest, + source_root: &source, + data_root: &data, + stable_root: &stable, + generation: "0.6.13", + arch: "arm64", + }; + + let initial = materialize_runtime(&request).unwrap(); + write_executable(&source.join("bin/dockerd"), b"incomplete-v2"); + let expected_v2 = format!("{:x}", Sha256::digest(b"dockerd-v2")); + write_manifest(&manifest, "0.6.13", &expected_v2, &kernel_sha); + + let error = materialize_runtime(&request).unwrap_err(); + + assert!(error.to_string().contains("SHA-256 mismatch")); + assert_eq!( + fs::read(initial.root.join("bin/dockerd")).unwrap(), + b"dockerd-v1" + ); + assert_eq!(fs::read_link(&stable).unwrap(), initial.root); + } + + #[test] + fn checksum_failure_publishes_neither_generation_nor_link() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let data = temp.path().join("data"); + let stable = temp.path().join("run/runtime"); + let kernel_sha = write_executable(&source.join("kernel/vmlinux"), b"kernel"); + write_executable(&source.join("bin/dockerd"), b"tampered"); + let manifest = temp.path().join("boot/0.6.13/manifest.json"); + write_manifest(&manifest, "0.6.13", &"0".repeat(64), &kernel_sha); + + let error = materialize_runtime(&MaterializeRequest { + manifest_path: &manifest, + source_root: &source, + data_root: &data, + stable_root: &stable, + generation: "0.6.13", + arch: "arm64", + }) + .unwrap_err(); + + assert!(error.to_string().contains("SHA-256 mismatch")); + assert!(!data.join("runtime/0.6.13").exists()); + assert_eq!(fs::read_dir(data.join("runtime")).unwrap().count(), 0); + assert!(!stable.exists()); + } +} diff --git a/tests/fex/README.md b/tests/fex/README.md index 8b8f33dc3..c6f25456d 100644 --- a/tests/fex/README.md +++ b/tests/fex/README.md @@ -55,24 +55,27 @@ Exit status: `0` no FAIL, `1` any FAIL, `2` only INFRA (nothing validated). ABX-375 provisions FEX like the other guest runtime binaries (dockerd, containerd, runc): a single aarch64 static-PIE `FEX` binary ships in the -boot-asset runtime bin set at `/arcbox/runtime/bin/FEX` (sibling `boot-assets` -repo + `assets.lock`; this is a coordinated two-repo change, not a local edit). +boot-asset runtime bin set. `/arcbox/runtime` is a read-only VirtioFS source; +the guest agent verifies the current architecture's manifest checksums, copies +the generation onto the Btrfs data disk, and publishes +`/run/arcbox/runtime/bin/FEX` (sibling `boot-assets` repo + `assets.lock`; this +is a coordinated two-repo change, not a local edit). ArcBox's FEX carries a small patch making it **binfmt-only**: it drops the FEXServer requirement, so nothing else needs to be running in the guest — no -daemon to reach across container mount namespaces. On boot, the rootfs -`/sbin/init` trampoline mounts the `arcbox` VirtioFS share, checks for -`/arcbox/runtime/bin/FEX`, and — if present — registers the x86_64 ELF handler -in `binfmt_misc` with `POCF` flags. The `F` (fix-binary) flag loads the -interpreter at registration time so containers inherit it across mount -namespaces. (This registration lives in the `boot-assets` rootfs init, not the -ArcBox guest agent.) +daemon to reach across container mount namespaces. Before Docker, Kubernetes, +or Sandbox startup, the guest agent materializes the runtime and registers the +local FEX path as the x86_64 ELF handler in `binfmt_misc` with `POCF` flags. +The `F` (fix-binary) flag loads the Btrfs-backed interpreter at registration +time so containers inherit it across mount namespaces. A repaired generation +is unregistered and registered again so the pinned interpreter inode cannot +refer to superseded content. Manual guest-side confirmation (until `abctl exec` diag is wired): ```bash # inside the HV guest: cat /proc/sys/fs/binfmt_misc/FEX-x86_64 # handler present + enabled -/arcbox/runtime/bin/FEX --version # FEX version +/run/arcbox/runtime/bin/FEX --version # FEX version # direct x86_64 exec (no Docker): ./some-x86_64-binary # runs via FEX ``` @@ -105,6 +108,7 @@ This directory provides the **reproducible procedure and pass/fail contract**. The live gate execution requires Apple Silicon hardware and a signed, bootable daemon. The ArcBox-side routing change (amd64 → HV/FEX, fail-closed) is implemented and unit/compile-tested in this repo; the FEX binary provisioning -and the rootfs `/sbin/init` binfmt registration live in the sibling -`boot-assets` repo. The FEX binary provisioning (boot-assets) and the live -A/B/C runs are the remaining hardware/sibling-repo steps. +and manifest live in the sibling `boot-assets` repo. Runtime materialization +and Btrfs-backed binfmt registration live in this repo's guest agent. The FEX +binary provisioning (boot-assets) and the live A/B/C runs are the remaining +hardware/sibling-repo steps. diff --git a/tests/fex/validate-fex.sh b/tests/fex/validate-fex.sh index 315a770d3..a79b5c5a3 100755 --- a/tests/fex/validate-fex.sh +++ b/tests/fex/validate-fex.sh @@ -57,7 +57,7 @@ printf 'docker context %s\n' "$CONTEXT" # it here; otherwise these are documented manual checks in README.md. if command -v abctl >/dev/null 2>&1; then printf 'guest kernel %s\n' "$(abctl exec -- uname -r 2>/dev/null || echo '? (run manually in guest)')" - printf 'fex version %s\n' "$(abctl exec -- /arcbox/runtime/bin/FEX --version 2>/dev/null || echo '? (run manually in guest)')" + printf 'fex version %s\n' "$(abctl exec -- /run/arcbox/runtime/bin/FEX --version 2>/dev/null || echo '? (run manually in guest)')" printf 'binfmt x86_64 %s\n' "$(abctl exec -- sh -c 'cat /proc/sys/fs/binfmt_misc/FEX-x86_64 2>/dev/null | head -1' 2>/dev/null || echo '? (run manually in guest)')" fi @@ -89,7 +89,7 @@ if [ "$amd64_out" = "x86_64" ]; then tag PASS "amd64 container reports x86_64 via HV/FEX (GATE A CORE)" elif printf '%s' "$amd64_out" | grep -qiE 'exec format error|requires fex|binfmt|no such file or directory|not provisioned'; then amd64_blocked=1 - tag INFRA "amd64 not served: FEX not provisioned in the HV guest (no x86_64 binfmt handler). Provision /arcbox/runtime/bin/FEX and run a daemon with ABX-375 routing. This is NOT a Gate A FAIL." + tag INFRA "amd64 not served: FEX not provisioned in the HV guest (no x86_64 binfmt handler). Provision /run/arcbox/runtime/bin/FEX and run a daemon with ABX-375 routing. This is NOT a Gate A FAIL." elif [ -z "$amd64_out" ]; then amd64_blocked=1 tag INFRA "amd64 produced no output (image pull / daemon issue)" @@ -115,7 +115,7 @@ if [ "$amd64_blocked" -ne 0 ]; then section "Summary" printf 'PASS=%d FAIL=%d UNSUPPORTED=%d INFRA=%d\n' "$pass" "$fail" "$unsupported" "$infra" echo "RESULT: BLOCKED — FEX not provisioned in the HV guest; amd64 gates (B/C) skipped." - echo "Provision /arcbox/runtime/bin/FEX and run a daemon with ABX-375 routing, then re-run." + echo "Provision /run/arcbox/runtime/bin/FEX and run a daemon with ABX-375 routing, then re-run." echo "This is NOT a FEX gate failure: do not resume ABX-374 on this basis." exit 2 fi @@ -206,8 +206,8 @@ fi if [ "$amd64_blocked" -ne 0 ]; then echo "RESULT: BLOCKED — FEX is not provisioned in the HV guest, so the amd64" echo "path could not be validated. This is NOT a FEX gate failure: do not" - echo "resume ABX-374 on this basis. Provision /arcbox/runtime/bin/FEX (boot-assets" - echo "rootfs init registers the x86_64 binfmt handler when present) and run a" + echo "resume ABX-374 on this basis. Provision /run/arcbox/runtime/bin/FEX (the" + echo "guest agent registers the Btrfs-backed x86_64 binfmt handler) and run a" echo "daemon with ABX-375 routing, then re-run. arm64 results above still apply." exit 2 fi From 519fc54cfcb0d2faaa1472c85632181be2686a3d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 23:09:56 +0800 Subject: [PATCH 2/4] fix(runtime): isolate guest assets by generation --- app/arcbox-cli/src/commands/boot.rs | 10 ++++++-- app/arcbox-core/src/runtime.rs | 25 ++++++++++++------- app/arcbox-core/src/runtime/assets.rs | 4 +-- app/arcbox-core/src/runtime/tests.rs | 21 ++++++++-------- app/arcbox-daemon/src/startup/assets.rs | 2 ++ app/arcbox-docker/src/handlers/admission.rs | 8 +++--- docs/data-directories.md | 15 ++++++----- .../src/agent/linux/runtime_cache.rs | 3 ++- 8 files changed, 54 insertions(+), 34 deletions(-) diff --git a/app/arcbox-cli/src/commands/boot.rs b/app/arcbox-cli/src/commands/boot.rs index 170b19890..325c30e07 100644 --- a/app/arcbox-cli/src/commands/boot.rs +++ b/app/arcbox-cli/src/commands/boot.rs @@ -382,7 +382,10 @@ async fn prefetch_json( } // Runtime binaries. - let runtime_bin_dir = root_data_dir.join("runtime/bin"); + let runtime_bin_dir = root_data_dir + .join("runtime") + .join(&provider.config().version) + .join("bin"); tokio::fs::create_dir_all(&runtime_bin_dir).await?; if let Err(e) = provider @@ -463,7 +466,10 @@ async fn prefetch_table( println!("\n Boot assets ready"); // 2. Runtime binaries. - let runtime_bin_dir = root_data_dir.join("runtime/bin"); + let runtime_bin_dir = root_data_dir + .join("runtime") + .join(&provider.config().version) + .join("bin"); tokio::fs::create_dir_all(&runtime_bin_dir).await?; provider diff --git a/app/arcbox-core/src/runtime.rs b/app/arcbox-core/src/runtime.rs index 8ce7761cf..28f7e7ec3 100644 --- a/app/arcbox-core/src/runtime.rs +++ b/app/arcbox-core/src/runtime.rs @@ -541,11 +541,11 @@ impl Runtime { /// - **VZ** uses Apple Rosetta — requires Apple Silicon *and* the System /// VM actually wiring the Rosetta share (`default_vm.rosetta`). If Rosetta /// is disabled the VZ guest has no x86 `binfmt` handler. - /// - **HV** uses FEX, which requires the interpreter provisioned as a - /// runtime binary at `/runtime/bin/FEX` (the same `runtime/bin` - /// set as `dockerd`/`containerd`, transported to the guest over the - /// `arcbox` VirtioFS share). The guest agent registers the `binfmt_misc` - /// handler after verifying and materializing FEX onto Btrfs. + /// - **HV** uses FEX, which requires the interpreter provisioned in the + /// active boot generation's runtime assets and transported to the guest + /// over the `arcbox` VirtioFS share. The guest agent registers the + /// `binfmt_misc` handler after verifying and materializing FEX onto + /// Btrfs. /// /// Fail-closed (ABX-375): when the active backend's translator is /// unavailable, amd64 requests must return a clear error rather than @@ -558,14 +558,15 @@ impl Runtime { && self.vm_lifecycle.config().default_vm.rosetta } arcbox_vmm::VmBackend::Hv => { - self.vm_lifecycle - .boot_assets() + let boot_assets = self.vm_lifecycle.boot_assets(); + boot_assets .cached_manifest_has_binary("FEX") .unwrap_or(false) && self .config .data_dir .join("runtime") + .join(&boot_assets.config().version) .join("bin") .join("FEX") .is_file() @@ -783,7 +784,13 @@ impl Runtime { // dockerd, containerd, containerd-shim-runc-v2, runc, docker-init, k3s, // and the optional FEX x86_64 interpreter for linux/amd64. ArcBox's // FEX carries a small patch making it binfmt-only — no FEXServer. - let runtime_bin_dir = self.config.data_dir.join("runtime/bin"); + let generation = self.vm_lifecycle.boot_assets().config().version.clone(); + let runtime_bin_dir = self + .config + .data_dir + .join("runtime") + .join(&generation) + .join("bin"); tokio::fs::create_dir_all(&runtime_bin_dir).await?; self.vm_lifecycle .boot_assets() @@ -791,7 +798,7 @@ impl Runtime { .await?; // Validate all guest binaries are present and executable (boot-blocking). - ensure_guest_binaries(&self.config.data_dir)?; + ensure_guest_binaries(&self.config.data_dir, &generation)?; // Boot the VM through the lifecycle manager first so the agent // handshake is observable on its own: `ensure_vm_ready` below covers diff --git a/app/arcbox-core/src/runtime/assets.rs b/app/arcbox-core/src/runtime/assets.rs index 5a8542e6f..b6ecb12d0 100644 --- a/app/arcbox-core/src/runtime/assets.rs +++ b/app/arcbox-core/src/runtime/assets.rs @@ -37,7 +37,7 @@ pub(super) fn check_executable(path: &Path, context: &str) -> Result<()> { /// These binaries are provisioned by `abctl boot prefetch` (release builds) or /// manually by developers (see `cargo xtask dev boot-assets`). This function /// only validates — it does not download or install anything. -pub(super) fn ensure_guest_binaries(data_dir: &Path) -> Result<()> { +pub(super) fn ensure_guest_binaries(data_dir: &Path, generation: &str) -> Result<()> { let agent_path = data_dir.join("bin/arcbox-agent"); check_executable( &agent_path, @@ -47,7 +47,7 @@ pub(super) fn ensure_guest_binaries(data_dir: &Path) -> Result<()> { ), )?; - let runtime_dir = data_dir.join("runtime/bin"); + let runtime_dir = data_dir.join("runtime").join(generation).join("bin"); for name in REQUIRED_RUNTIME_ASSETS { check_executable( &runtime_dir.join(name), diff --git a/app/arcbox-core/src/runtime/tests.rs b/app/arcbox-core/src/runtime/tests.rs index de7d306cd..e0cddd3d2 100644 --- a/app/arcbox-core/src/runtime/tests.rs +++ b/app/arcbox-core/src/runtime/tests.rs @@ -8,18 +8,19 @@ use std::path::PathBuf; fn test_ensure_guest_binaries_ok() { let temp_dir = tempfile::tempdir().unwrap(); let data_dir = temp_dir.path(); + let generation = "0.6.13"; std::fs::create_dir_all(data_dir.join("bin")).unwrap(); - std::fs::create_dir_all(data_dir.join("runtime/bin")).unwrap(); + std::fs::create_dir_all(data_dir.join("runtime").join(generation).join("bin")).unwrap(); for name in [ "bin/arcbox-agent", - "runtime/bin/dockerd", - "runtime/bin/containerd", - "runtime/bin/containerd-shim-runc-v2", - "runtime/bin/runc", - "runtime/bin/docker-init", - "runtime/bin/k3s", + "runtime/0.6.13/bin/dockerd", + "runtime/0.6.13/bin/containerd", + "runtime/0.6.13/bin/containerd-shim-runc-v2", + "runtime/0.6.13/bin/runc", + "runtime/0.6.13/bin/docker-init", + "runtime/0.6.13/bin/k3s", ] { let path = data_dir.join(name); std::fs::write(&path, b"binary").unwrap(); @@ -30,14 +31,14 @@ fn test_ensure_guest_binaries_ok() { } } - let result = ensure_guest_binaries(data_dir); + let result = ensure_guest_binaries(data_dir, generation); assert!(result.is_ok(), "expected success, got {result:?}"); } #[test] fn test_ensure_guest_binaries_missing_agent() { let temp_dir = tempfile::tempdir().unwrap(); - let err = ensure_guest_binaries(temp_dir.path()).unwrap_err(); + let err = ensure_guest_binaries(temp_dir.path(), "0.6.13").unwrap_err(); assert!( err.to_string().contains("agent binary not found"), "got: {err}" @@ -58,7 +59,7 @@ fn test_ensure_guest_binaries_missing_runtime() { std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap(); } - let err = ensure_guest_binaries(data_dir).unwrap_err(); + let err = ensure_guest_binaries(data_dir, "0.6.13").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("runtime binary"), "got: {msg}"); } diff --git a/app/arcbox-daemon/src/startup/assets.rs b/app/arcbox-daemon/src/startup/assets.rs index 9df809464..48c3558af 100644 --- a/app/arcbox-daemon/src/startup/assets.rs +++ b/app/arcbox-daemon/src/startup/assets.rs @@ -96,6 +96,8 @@ pub fn find_bundle_contents() -> Option { /// ```text /// Contents/Resources/assets/{version}/ → ~/.arcbox/boot/{version}/ /// Contents/Resources/runtime/ → ~/.arcbox/runtime/ +/// bin/ → host Docker CLI tools +/// {version}/{bin,kernel}/ → guest runtime transport source /// Contents/Resources/bin/arcbox-agent → ~/.arcbox/bin/arcbox-agent /// Contents/Resources/bin/vm-agent → ~/.arcbox/bin/vm-agent /// ``` diff --git a/app/arcbox-docker/src/handlers/admission.rs b/app/arcbox-docker/src/handlers/admission.rs index 03663b57f..6f0bf3251 100644 --- a/app/arcbox-docker/src/handlers/admission.rs +++ b/app/arcbox-docker/src/handlers/admission.rs @@ -6,10 +6,10 @@ use crate::error::{DockerError, Result}; /// Fail-closed admission for a routed runtime workload. /// /// `linux/amd64` containers run via FEX inside the HV guest. If FEX is -/// not provisioned (`/runtime/bin/FEX` absent → no x86_64 `binfmt_misc` -/// handler in the guest), this returns a clear error instead of letting the -/// request silently route to VZ/Rosetta or QEMU, or fail later with a -/// cryptic `exec format error`. Native (arm64) workloads are always admitted. +/// absent from the active boot generation, the guest has no x86_64 +/// `binfmt_misc` handler. This returns a clear error instead of letting the +/// request silently route to VZ/Rosetta or QEMU, or fail later with a cryptic +/// `exec format error`. Native (arm64) workloads are always admitted. pub async fn require_amd64_runtime( state: &AppState, route: crate::routing::RoutingDecision, diff --git a/docs/data-directories.md b/docs/data-directories.md index 25a76a0ac..6de60f099 100644 --- a/docs/data-directories.md +++ b/docs/data-directories.md @@ -73,16 +73,19 @@ Assets are version-keyed. The daemon downloads them on first launch if not already cached. When running inside the Desktop app bundle, they are seeded from `Contents/Resources/assets/{version}/` to avoid a network round-trip. -### 1.5 `runtime/` — Guest Runtime Binaries +### 1.5 `runtime/` — Runtime Binaries | Path | Purpose | Creator | |------|---------|---------| -| `runtime/bin/` | dockerd, containerd, runc, etc. | daemon (bundle seed) | +| `runtime/bin/` | Host Docker CLI tools | daemon (bundle seed) | +| `runtime/{version}/bin/` | Guest dockerd, containerd, runc, etc. | daemon (bundle seed) | +| `runtime/{version}/kernel/` | Sandbox guest kernel | daemon (bundle seed) | Seeded from `Contents/Resources/runtime/` when running inside the app bundle. -Exposed to the guest VM via VirtioFS at `/arcbox/runtime/` as a transport -source. The agent verifies each manifest entry and materializes it onto the -guest Btrfs data disk before execution. +Each guest generation is exposed via VirtioFS at +`/arcbox/runtime/{version}/` as a transport source. The agent verifies each +manifest entry and materializes it onto the guest Btrfs data disk before +execution. ### 1.6 `bin/` — User Executables @@ -237,7 +240,7 @@ These paths exist inside the Linux VM, not on the macOS host. Defined in |------------|----------------|--------------|---------| | `/arcbox` | `~/.arcbox/` | `arcbox` | ArcBox data sharing | | `/arcbox/log/` | `~/.arcbox/log/` | — | Guest logs visible from host | -| `/arcbox/runtime/` | `~/.arcbox/runtime/` | — | Runtime transport source (never executed directly) | +| `/arcbox/runtime/{version}/` | `~/.arcbox/runtime/{version}/` | — | Runtime transport source (never executed directly) | | `/Users` | `/Users` | `users` | macOS home directory passthrough | Tags defined in `common/arcbox-constants/src/virtiofs.rs`. diff --git a/guest/arcbox-agent/src/agent/linux/runtime_cache.rs b/guest/arcbox-agent/src/agent/linux/runtime_cache.rs index 7270125d1..cce3f8d78 100644 --- a/guest/arcbox-agent/src/agent/linux/runtime_cache.rs +++ b/guest/arcbox-agent/src/agent/linux/runtime_cache.rs @@ -45,10 +45,11 @@ fn ensure_local_runtime_blocking() -> Result { let manifest_path = Path::new(BOOT_SOURCE_ROOT) .join(&generation) .join("manifest.json"); + let source_root = Path::new(RUNTIME_SOURCE_ROOT).join(&generation); let runtime = materialize_runtime(&MaterializeRequest { manifest_path: &manifest_path, - source_root: Path::new(RUNTIME_SOURCE_ROOT), + source_root: &source_root, data_root: Path::new(BTRFS_TEMP_MOUNT), stable_root: Path::new(ARCBOX_RUNTIME_DIR), generation: &generation, From 9296e8d3597fb2147822300825e1fffe3fe58b02 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 23:12:43 +0800 Subject: [PATCH 3/4] fix(runtime): preserve startup invariants --- app/arcbox-core/src/agent_client.rs | 29 +++++++++++++------ app/arcbox-core/src/vm_lifecycle/boot.rs | 9 +++--- app/arcbox-core/src/vm_lifecycle/tests.rs | 21 +++++++++----- app/arcbox-core/src/vm_lifecycle/types.rs | 10 +++---- guest/arcbox-agent/src/agent/linux/runtime.rs | 14 ++++----- .../src/agent/linux/runtime_cache.rs | 4 +-- guest/arcbox-agent/src/runtime_materialize.rs | 28 ++++++------------ 7 files changed, 61 insertions(+), 54 deletions(-) diff --git a/app/arcbox-core/src/agent_client.rs b/app/arcbox-core/src/agent_client.rs index b9ba220f8..1c311b786 100644 --- a/app/arcbox-core/src/agent_client.rs +++ b/app/arcbox-core/src/agent_client.rs @@ -330,10 +330,10 @@ impl AgentClient { /// Verifies the agent's protocol version from a ping response. /// /// Rejects agents older than - /// [`arcbox_constants::wire::MIN_AGENT_PROTOCOL_VERSION`] — including - /// pre-handshake agents that report `0` — so a stale staged agent - /// fails the boot with an actionable error instead of silently - /// misdecoding newer requests (proto3 defaults unknown fields). + /// [`arcbox_constants::wire::MIN_AGENT_PROTOCOL_VERSION`]. Protocol `0` + /// means no compatible handshake completed and may carry a boot-contract + /// rejection from a current agent; positive older versions identify a + /// stale staged agent. /// A *newer* agent than the host only warns: protocol evolution is /// additive, so newer agents understand older hosts. /// @@ -344,6 +344,13 @@ impl AgentClient { pub fn check_agent_protocol(resp: &PingResponse) -> Result<()> { use arcbox_constants::wire::{AGENT_PROTOCOL_VERSION, MIN_AGENT_PROTOCOL_VERSION}; + if resp.protocol_version == 0 { + return Err(CoreError::Machine(format!( + "guest agent did not complete a compatible handshake \ + (agent version {}, response {:?}); fix the reported guest boot contract", + resp.version, resp.message, + ))); + } if resp.protocol_version < MIN_AGENT_PROTOCOL_VERSION { return Err(CoreError::Machine(format!( "guest agent is incompatible with this daemon: agent protocol {} \ @@ -1967,12 +1974,15 @@ mod tests { } #[test] - fn pre_handshake_agent_is_rejected() { - // Agents older than the handshake never set the field → proto3 - // default 0 → must be rejected, not silently accepted. - let err = AgentClient::check_agent_protocol(&ping_response(0)) - .expect_err("protocol 0 must be rejected"); + fn boot_contract_rejection_is_not_reported_as_a_stale_agent() { + let mut response = ping_response(0); + response.message = + "incompatible host boot contract: missing arcbox.runtime_generation".to_string(); + let err = + AgentClient::check_agent_protocol(&response).expect_err("protocol 0 must be rejected"); assert!(err.to_string().contains("incompatible")); + assert!(err.to_string().contains("arcbox.runtime_generation")); + assert!(!err.to_string().contains("stale")); } #[test] @@ -1981,6 +1991,7 @@ mod tests { let err = AgentClient::check_agent_protocol(&ping_response(previous)) .expect_err("previous protocol must be rejected"); assert!(err.to_string().contains("incompatible")); + assert!(err.to_string().contains("stale")); } #[test] diff --git a/app/arcbox-core/src/vm_lifecycle/boot.rs b/app/arcbox-core/src/vm_lifecycle/boot.rs index 480d471ba..9b4d7c1f4 100644 --- a/app/arcbox-core/src/vm_lifecycle/boot.rs +++ b/app/arcbox-core/src/vm_lifecycle/boot.rs @@ -153,14 +153,13 @@ impl LifecycleShared { let desired_boot = match self.resolve_desired_boot().await { Ok(boot) => Some(boot), Err(e) => { - tracing::warn!(error = %e, "could not resolve desired boot params; skipping drift check"); + tracing::warn!(error = %e, "could not resolve desired boot params; skipping boot-dependent drift fields"); None } }; - let drift_reason = match (existing_machine.as_ref(), desired_boot.as_ref()) { - (Some(m), Some(boot)) => machine_drift_reason(m, &self.config.default_vm, boot), - _ => None, - }; + let drift_reason = existing_machine.as_ref().and_then(|machine| { + machine_drift_reason(machine, &self.config.default_vm, desired_boot.as_ref()) + }); if let Some(field) = drift_reason { let m = existing_machine.as_ref().unwrap(); tracing::warn!( diff --git a/app/arcbox-core/src/vm_lifecycle/tests.rs b/app/arcbox-core/src/vm_lifecycle/tests.rs index 54e42be95..2bcf65eba 100644 --- a/app/arcbox-core/src/vm_lifecycle/tests.rs +++ b/app/arcbox-core/src/vm_lifecycle/tests.rs @@ -193,33 +193,39 @@ fn machine_drift_detects_each_overridable_field() { let current = sample_machine(want.cpus, want.memory_mb, &boot.kernel, &boot.cmdline); // Matching machine: no drift. - assert_eq!(machine_drift_reason(¤t, &want, &boot), None); + assert_eq!(machine_drift_reason(¤t, &want, Some(&boot)), None); // Each overridable field, changed independently, is detected. let mut m = current.clone(); m.cpus = want.cpus + 1; - assert_eq!(machine_drift_reason(&m, &want, &boot), Some("cpus")); + assert_eq!(machine_drift_reason(&m, &want, Some(&boot)), Some("cpus")); let mut m = current.clone(); m.memory_mb = want.memory_mb + 1; - assert_eq!(machine_drift_reason(&m, &want, &boot), Some("memory_mb")); + assert_eq!( + machine_drift_reason(&m, &want, Some(&boot)), + Some("memory_mb") + ); let mut m = current.clone(); m.kernel = Some("/other-kernel".to_string()); - assert_eq!(machine_drift_reason(&m, &want, &boot), Some("kernel")); + assert_eq!(machine_drift_reason(&m, &want, Some(&boot)), Some("kernel")); // The cmdline gap that previously slipped through (e.g. arm64.nosve // added/removed without bumping the boot-asset version). let mut m = current.clone(); m.cmdline = Some("console=hvc0 earlycon arm64.nosve".to_string()); - assert_eq!(machine_drift_reason(&m, &want, &boot), Some("cmdline")); + assert_eq!( + machine_drift_reason(&m, &want, Some(&boot)), + Some("cmdline") + ); // A machine persisted before the ext4 metadata volume (two disks) must // be recreated so the guest receives vdc. let mut m = current.clone(); m.block_devices.pop(); assert_eq!( - machine_drift_reason(&m, &want, &boot), + machine_drift_reason(&m, &want, Some(&boot)), Some("block_devices") ); @@ -231,9 +237,10 @@ fn machine_drift_detects_each_overridable_field() { read_only: true, }); assert_eq!( - machine_drift_reason(&m, &want, &boot), + machine_drift_reason(&m, &want, Some(&boot)), Some("block_devices") ); + assert_eq!(machine_drift_reason(&m, &want, None), Some("block_devices")); } #[test] diff --git a/app/arcbox-core/src/vm_lifecycle/types.rs b/app/arcbox-core/src/vm_lifecycle/types.rs index 68078892d..2bbf6199b 100644 --- a/app/arcbox-core/src/vm_lifecycle/types.rs +++ b/app/arcbox-core/src/vm_lifecycle/types.rs @@ -181,22 +181,22 @@ pub(super) struct DesiredBoot { pub(super) fn machine_drift_reason( persisted: &MachineInfo, want: &DefaultVmConfig, - boot: &DesiredBoot, + boot: Option<&DesiredBoot>, ) -> Option<&'static str> { if persisted.cpus != want.cpus { Some("cpus") } else if persisted.memory_mb != want.memory_mb { Some("memory_mb") - } else if persisted.kernel.as_deref() != Some(boot.kernel.as_str()) { - Some("kernel") - } else if persisted.cmdline.as_deref() != Some(boot.cmdline.as_str()) { - Some("cmdline") } else if persisted.block_devices.len() != BASE_MACHINE_DISK_COUNT { // A machine persisted before or after the current disk layout // carries a different disk count; recreating // rewrites the machine record — image files are untouched — so the // guest receives exactly the current device set. Some("block_devices") + } else if boot.is_some_and(|boot| persisted.kernel.as_deref() != Some(boot.kernel.as_str())) { + Some("kernel") + } else if boot.is_some_and(|boot| persisted.cmdline.as_deref() != Some(boot.cmdline.as_str())) { + Some("cmdline") } else { None } diff --git a/guest/arcbox-agent/src/agent/linux/runtime.rs b/guest/arcbox-agent/src/agent/linux/runtime.rs index 221b683af..d57a30639 100644 --- a/guest/arcbox-agent/src/agent/linux/runtime.rs +++ b/guest/arcbox-agent/src/agent/linux/runtime.rs @@ -748,6 +748,13 @@ async fn try_start_bundled_runtime() -> Result { let _guard = runtime_start_lock().lock().await; let mut notes = Vec::new(); + // Prepare /run and the other mounts needed by runtime materialization. + let prereq_notes = ensure_runtime_prerequisites(); + if !prereq_notes.is_empty() { + tracing::info!(prerequisites = %prereq_notes.join("; "), "runtime prerequisites"); + } + notes.extend(prereq_notes); + match ensure_local_runtime().await { Ok(note) => notes.push(note), Err(error) => return Err(format!("local runtime setup failed: {error}")), @@ -757,13 +764,6 @@ async fn try_start_bundled_runtime() -> Result { return Ok(notes.join("; ")); } - // Ensure kernel/filesystem prerequisites before spawning daemons. - let prereq_notes = ensure_runtime_prerequisites(); - if !prereq_notes.is_empty() { - tracing::info!(prerequisites = %prereq_notes.join("; "), "runtime prerequisites"); - } - notes.extend(prereq_notes); - let runtime_bin_dir = PathBuf::from(ARCBOX_RUNTIME_BIN_DIR); let missing = missing_runtime_binaries_at(&runtime_bin_dir); if !missing.is_empty() { diff --git a/guest/arcbox-agent/src/agent/linux/runtime_cache.rs b/guest/arcbox-agent/src/agent/linux/runtime_cache.rs index cce3f8d78..e47595f6f 100644 --- a/guest/arcbox-agent/src/agent/linux/runtime_cache.rs +++ b/guest/arcbox-agent/src/agent/linux/runtime_cache.rs @@ -38,7 +38,6 @@ fn ensure_local_runtime_blocking() -> Result { return Ok(note.clone()); } - unregister_fex()?; let data_note = ensure_data_mount().map_err(anyhow::Error::msg)?; let generation = runtime_generation().map_err(anyhow::Error::msg)?; let arch = manifest_arch()?; @@ -55,6 +54,7 @@ fn ensure_local_runtime_blocking() -> Result { generation: &generation, arch, })?; + unregister_fex()?; let fex_note = register_local_fex(Path::new(ARCBOX_RUNTIME_BIN_DIR))?; let action = if runtime.reused { "reused" @@ -128,7 +128,7 @@ fn register_local_fex(runtime_bin_dir: &Path) -> Result { fn unregister_fex() -> Result<()> { if cfg!(target_arch = "aarch64") && Path::new(FEX_BINFMT_ENTRY).exists() { // `F` pins the interpreter inode, so remove the legacy VirtioFS entry - // before materialization can fail. + // immediately before registering the materialized interpreter. std::fs::write(FEX_BINFMT_ENTRY, b"-1\n") .context("remove stale FEX binfmt registration")?; } diff --git a/guest/arcbox-agent/src/runtime_materialize.rs b/guest/arcbox-agent/src/runtime_materialize.rs index 082bd3256..0fc75c052 100644 --- a/guest/arcbox-agent/src/runtime_materialize.rs +++ b/guest/arcbox-agent/src/runtime_materialize.rs @@ -69,6 +69,7 @@ struct PlannedAsset { source: PathBuf, destination: PathBuf, sha256: String, + expected_len: Option, } /// Copies the manifest's current-architecture assets to Btrfs and atomically @@ -151,8 +152,12 @@ fn plan_assets( if !destinations.insert(relative.clone()) { bail!("duplicate runtime asset destination {}", relative.display()); } + let source = source_root.join(&relative); assets.push(PlannedAsset { - source: source_root.join(&relative), + expected_len: fs::symlink_metadata(&source) + .ok() + .and_then(|metadata| metadata.file_type().is_file().then_some(metadata.len())), + source, destination: relative, sha256: target.sha256.to_ascii_lowercase(), }); @@ -289,28 +294,13 @@ fn generation_complete( fs::symlink_metadata(&path).is_ok_and(|metadata| { metadata.file_type().is_file() && metadata.permissions().mode() & 0o111 != 0 - && sha256_file(&path).is_ok_and(|digest| digest == asset.sha256) + && asset + .expected_len + .is_none_or(|expected_len| metadata.len() == expected_len) }) }) } -fn sha256_file(path: &Path) -> Result { - let mut file = - File::open(path).with_context(|| format!("open runtime asset {}", path.display()))?; - let mut digest = Sha256::new(); - let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; - loop { - let read = file - .read(&mut buffer) - .with_context(|| format!("read runtime asset {}", path.display()))?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - Ok(format!("{:x}", digest.finalize())) -} - fn publish_generation(temporary_root: &Path, generation_root: &Path) -> Result<()> { match fs::symlink_metadata(generation_root) { Ok(_) => replace_generation(temporary_root, generation_root), From 225dbe2c245c62c0a43e149fe296b3517b6eb828 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 00:14:36 +0800 Subject: [PATCH 4/4] chore(runtime): adopt boot assets 0.8.0 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- assets.lock | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b766a070f..17cf99f0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,9 +277,9 @@ dependencies = [ [[package]] name = "arcbox-boot" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2925f99fcc4097b1b8eeb12bd1364f858aeefd9426ee57e8c94229a3b6b002d5" +checksum = "239431ac6f459a9fd12f63e14d4492af7997046984b492c5b30be56a025068e8" dependencies = [ "camino", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 3180befb7..90ced6667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -212,7 +212,7 @@ arcbox-asset = { version = "0.6.2", path = "common/arcbox-asset", features = ["d arcbox-docker-tools = { version = "0.6.2", path = "app/arcbox-docker-tools" } # x-release-please-version # External crates -arcbox-boot = { version = "0.7.0", features = ["download"] } +arcbox-boot = { version = "0.8.0", features = ["download"] } macos-resolver = "0.2.0" fc-sdk = "0.2.3" diff --git a/assets.lock b/assets.lock index 986d7724b..916bed6c4 100644 --- a/assets.lock +++ b/assets.lock @@ -14,9 +14,9 @@ # and the [boot] bump. [boot] -version = "0.6.13" +version = "0.8.0" cdn = "https://boot.arcboxcdn.com" -manifest_sha256 = "7bff7252fa38a74fd1a513baffeceb80eaddb556b11eec55438503ac7e9ec6c0" +manifest_sha256 = "4438fdadb94f70d31911dd7588c6cc61250040c22df4f913f2d573f9a7a7186e" [[tools]] name = "docker"