diff --git a/Cargo.lock b/Cargo.lock index ce3819712..8705cb10f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,12 +336,14 @@ dependencies = [ "bytes", "clap", "dirs", + "filetime", "http-body-util", "libc", "libproc", "macos-resolver", "sentry", "serde_json", + "tempfile", "tokio", "tokio-stream", "tokio-util", @@ -1664,13 +1666,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -2493,10 +2494,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.3", ] [[package]] @@ -3113,7 +3111,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -3195,12 +3193,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plotters" version = "0.3.7" @@ -3567,15 +3559,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "redox_users" version = "0.5.2" diff --git a/app/arcbox-daemon/Cargo.toml b/app/arcbox-daemon/Cargo.toml index 3545ee33c..c7890c011 100644 --- a/app/arcbox-daemon/Cargo.toml +++ b/app/arcbox-daemon/Cargo.toml @@ -37,6 +37,7 @@ bytes.workspace = true http-body-util = "0.1" tokio-util = "0.7" arcbox-docker-tools.workspace = true +filetime = "0.2.29" [target.'cfg(target_os = "macos")'.dependencies] macos-resolver = { workspace = true } @@ -55,3 +56,6 @@ gic = ["arcbox-core/gic"] [lints] workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/app/arcbox-daemon/src/context.rs b/app/arcbox-daemon/src/context.rs index 70747d3c2..7f98cab2f 100644 --- a/app/arcbox-daemon/src/context.rs +++ b/app/arcbox-daemon/src/context.rs @@ -3,9 +3,9 @@ //! The startup sequence produces progressively richer context types: //! //! ```text -//! init_early() → EarlyContext (no lock, no runtime) -//! acquire_lock() → DaemonContext (lock held, no runtime yet) -//! init_runtime() → Arc (also fills SharedRuntime for gRPC) +//! Startup::prepare_host() → EarlyContext (no lock, no runtime) +//! Startup::acquire_daemon_lease() → DaemonContext (lock held, no runtime yet) +//! Startup::boot_runtime() → Arc (also fills SharedRuntime for gRPC) //! ``` //! //! This encoding makes it a compile error to access the daemon lock @@ -20,12 +20,12 @@ use tokio_util::sync::CancellationToken; use crate::startup::DaemonLock; -/// Pre-lock context returned by [`startup::init_early`]. +/// Pre-lock context produced by the startup pipeline. /// /// Contains everything needed to start the gRPC SystemService (so /// clients can observe progress), but the daemon lock has not been -/// acquired yet. Consumed by [`startup::acquire_lock`] to produce -/// a [`DaemonContext`]. +/// acquired yet. Consumed by the daemon lease phase to produce a +/// [`DaemonContext`]. pub struct EarlyContext { pub layout: HostLayout, pub shared_runtime: SharedRuntime, diff --git a/app/arcbox-daemon/src/main.rs b/app/arcbox-daemon/src/main.rs index 72b131818..e116be3b4 100644 --- a/app/arcbox-daemon/src/main.rs +++ b/app/arcbox-daemon/src/main.rs @@ -10,11 +10,8 @@ mod shutdown; mod startup; use anyhow::Result; -use arcbox_api::SetupPhase; use arcbox_logging::LogConfig; use clap::Parser; -use macos_resolver::FileResolver; -use tracing::info; #[derive(Debug, Parser)] #[command(name = "arcbox-daemon")] @@ -99,56 +96,23 @@ fn sentry_environment() -> Option { } async fn run(args: DaemonArgs) -> Result<()> { - info!("Starting ArcBox daemon..."); - - // Phase 1: directories, config, sockets — no runtime yet. - let early = startup::init_early(args).await?; - - // Acquire exclusive daemon lock. Terminates any stale daemon that - // still holds the lock (up to ~30 s SIGTERM wait). Must complete - // before start_grpc because the old daemon may be listening on the - // same socket paths. Consumes EarlyContext, producing a - // DaemonContext with a guaranteed lock. - let ctx = startup::acquire_lock(early).await?; - - // Start gRPC with all services. Machine/Sandbox return UNAVAILABLE - // until runtime is ready. SystemService works immediately so clients - // can observe the full startup progression (CLEANING_UP → - // INITIALIZING → DOWNLOADING_ASSETS → ASSETS_READY → …). - let shared_runtime = ctx.shared_runtime.clone(); - let grpc = services::start_grpc(&ctx, shared_runtime).await?; - - // Wait for residual resource holders (e.g. orphaned - // Virtualization.framework XPC helpers holding docker.img). - // Visible to gRPC clients as the CLEANING_UP phase. - startup::wait_for_resources(&ctx).await?; - - // Phase 2: seed/download boot assets, build runtime, start VM. - // Progress is visible to gRPC clients via WatchSetupStatus. - let runtime = startup::init_runtime(&ctx).await?; - - // Phase 3: start remaining services that require the runtime. - let handles = services::start_services(&ctx, &runtime, grpc).await?; - recovery::run(&ctx, &runtime).await; - - check_resolver_installed(&ctx.dns_domain); - ctx.setup_state.set_phase(SetupPhase::Ready, "Daemon ready"); - - println!("ArcBox daemon started"); - println!(" Docker API: {}", ctx.layout.docker_socket.display()); - println!(" gRPC API: {}", ctx.layout.grpc_socket.display()); - println!(" DNS: 127.0.0.1:{}", ctx.dns_port); - println!(" Data: {}", ctx.layout.data_dir.display()); - println!(); - println!("Use 'arcbox docker enable' to configure Docker CLI integration."); - println!("Press Ctrl+C to stop."); - - shutdown::run(ctx, handles).await -} - -fn check_resolver_installed(domain: &str) { - let resolver = FileResolver::new("arcbox"); - if !resolver.is_registered(domain) { - println!("Hint: Run 'sudo arcbox dns install' to enable *.{domain} DNS resolution."); - } + let ready = startup::Startup::from_args(args) + .prepare_host() + .await? + .acquire_daemon_lease() + .await? + .start_control_plane() + .await? + .release_stale_resources() + .await? + .prepare_assets() + .await? + .boot_runtime() + .await? + .start_runtime_services() + .await? + .mark_ready() + .await?; + + shutdown::run(ready.ctx, ready.handles).await } diff --git a/app/arcbox-daemon/src/recovery.rs b/app/arcbox-daemon/src/recovery.rs index 2a517d1ad..98fecfea5 100644 --- a/app/arcbox-daemon/src/recovery.rs +++ b/app/arcbox-daemon/src/recovery.rs @@ -143,38 +143,6 @@ fn spawn_vmnet_route_reconcile(setup_state: Arc, bridge: })); } -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(all(target_os = "macos", feature = "vmnet"))] - #[test] - fn cold_start_route_plan_uses_vmnet_bridge() { - let plan = cold_start_route_plan(Some("bridge100".to_string()), None); - - assert_eq!( - plan, - Some(ColdStartRoutePlan::VmnetBridge("bridge100".to_string())) - ); - } - - #[cfg(all(target_os = "macos", feature = "vmnet"))] - #[test] - fn cold_start_route_plan_skips_when_vmnet_bridge_is_unknown() { - let plan = cold_start_route_plan(None, Some("fe:b2:14:4d:a4:64".to_string())); - - assert_eq!(plan, None); - } - - #[cfg(all(target_os = "macos", not(feature = "vmnet")))] - #[test] - fn cold_start_route_plan_uses_bridge_mac_without_vmnet() { - let plan = cold_start_route_plan(Some("bridge100".to_string()), Some("mac".to_string())); - - assert_eq!(plan, Some(ColdStartRoutePlan::BridgeMac("mac".to_string()))); - } -} - // ============================================================================= // Docker CLI tools // ============================================================================= @@ -336,3 +304,35 @@ async fn recover_container_networking( ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(target_os = "macos", feature = "vmnet"))] + #[test] + fn cold_start_route_plan_uses_vmnet_bridge() { + let plan = cold_start_route_plan(Some("bridge100".to_string()), None); + + assert_eq!( + plan, + Some(ColdStartRoutePlan::VmnetBridge("bridge100".to_string())) + ); + } + + #[cfg(all(target_os = "macos", feature = "vmnet"))] + #[test] + fn cold_start_route_plan_skips_when_vmnet_bridge_is_unknown() { + let plan = cold_start_route_plan(None, Some("fe:b2:14:4d:a4:64".to_string())); + + assert_eq!(plan, None); + } + + #[cfg(all(target_os = "macos", not(feature = "vmnet")))] + #[test] + fn cold_start_route_plan_uses_bridge_mac_without_vmnet() { + let plan = cold_start_route_plan(Some("bridge100".to_string()), Some("mac".to_string())); + + assert_eq!(plan, Some(ColdStartRoutePlan::BridgeMac("mac".to_string()))); + } +} diff --git a/app/arcbox-daemon/src/startup/assets.rs b/app/arcbox-daemon/src/startup/assets.rs index 92d7dd832..9e8c985c4 100644 --- a/app/arcbox-daemon/src/startup/assets.rs +++ b/app/arcbox-daemon/src/startup/assets.rs @@ -8,6 +8,58 @@ use arcbox_api::{SetupPhase, SetupState}; use arcbox_core::BootAssetProvider; use tracing::info; +/// Assets reconciled into the daemon data directory for this startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct PreparedAssets { + agent: PreparedAgent, +} + +impl PreparedAssets { + /// Returns where the staged `arcbox-agent` came from. + pub(super) fn agent(self) -> PreparedAgent { + self.agent + } +} + +/// Source of the staged `~/.arcbox/bin/arcbox-agent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PreparedAgent { + /// The launched app bundle provided the staged agent. + Bundle, + /// The boot asset cache provided the staged agent because no bundle agent existed. + BootCache, + /// No agent was available from either source. + Unavailable, +} + +/// Reconciles all startup assets into `data_dir`. +/// +/// Bundle contents are seeded first so Sparkle app updates refresh staged +/// binaries. Network/download fallback then fills any missing boot assets, and +/// the boot-cache agent is staged only if the bundle did not provide one. +pub(super) async fn prepare( + data_dir: &Path, + setup_state: &Arc, +) -> Result { + let seed_data_dir = data_dir.to_path_buf(); + let bundle_seed = tokio::task::spawn_blocking(move || seed_from_bundle(&seed_data_dir)) + .await + .context("bundle seed task panicked")? + .context("bundle seed failed")?; + + ensure_boot_assets(data_dir, setup_state).await?; + + let fallback_data_dir = data_dir.to_path_buf(); + let agent = tokio::task::spawn_blocking(move || { + ensure_agent_binary_fallback(&fallback_data_dir, bundle_seed) + }) + .await + .context("agent fallback task panicked")? + .context("agent fallback failed")?; + + Ok(PreparedAssets { agent }) +} + /// Returns the `Contents/` directory if the daemon is running inside an app bundle. /// /// Finds the main app bundle's `Contents/` directory by walking up from the @@ -36,7 +88,9 @@ pub fn find_bundle_contents() -> Option { /// Seeds all assets from the app bundle to `~/.arcbox/` if available. /// /// Copies boot assets, runtime binaries, and the agent binary from the -/// bundle so the daemon can start without network on first launch. +/// bundle so the daemon can start without network on first launch. Files are +/// refreshed whenever the bundle's copy differs (see [`copy_if_changed`]), so a +/// staged binary can't go stale across an app update. /// /// Bundle layout: /// ```text @@ -44,11 +98,12 @@ pub fn find_bundle_contents() -> Option { /// Contents/Resources/runtime/ → ~/.arcbox/runtime/ /// Contents/Resources/bin/arcbox-agent → ~/.arcbox/bin/arcbox-agent /// ``` -pub(super) fn seed_from_bundle(data_dir: &Path) { +pub(super) fn seed_from_bundle(data_dir: &Path) -> Result { let Some(contents) = find_bundle_contents() else { - return; + return Ok(BundleSeed::default()); }; tracing::info!("App bundle detected: {}", contents.display()); + let mut seed = BundleSeed::default(); // 1. Boot assets: kernel, rootfs, manifest. let version = arcbox_core::boot_asset_version(); @@ -72,45 +127,128 @@ pub(super) fn seed_from_bundle(data_dir: &Path) { // 3. Agent binary. let agent_src = contents.join("Resources/bin/arcbox-agent"); + if agent_src.exists() { + seed.agent = seed_agent_from_bundle(&agent_src, data_dir) + .context("Failed to seed arcbox-agent from bundle")?; + } + + Ok(seed) +} + +/// Result of copying bundle-provided assets into the runtime data directory. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct BundleSeed { + /// Whether the app bundle provided and successfully synced `arcbox-agent`. + agent: AgentSeed, +} + +impl BundleSeed { + /// Returns whether the boot cache may be used to stage `arcbox-agent`. + /// + /// A bundle-provided agent is authoritative because it ships with the app + /// version that just launched. Falling back after that would let an older + /// boot cache overwrite the freshly staged bundle agent. + pub(super) fn needs_agent_fallback(self) -> bool { + self.agent == AgentSeed::Missing + } +} + +/// Source state for the staged `~/.arcbox/bin/arcbox-agent`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum AgentSeed { + /// No bundle agent was available; the boot cache may be used as fallback. + #[default] + Missing, + /// The bundle agent is the staged copy's authoritative source. + Bundle, +} + +fn seed_agent_from_bundle(agent_src: &Path, data_dir: &Path) -> Result { let agent_dst = data_dir.join("bin/arcbox-agent"); - if agent_src.exists() && !agent_dst.exists() { - if let Err(e) = (|| -> std::io::Result<()> { - std::fs::create_dir_all(agent_dst.parent().unwrap())?; - std::fs::copy(&agent_src, &agent_dst)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&agent_dst, std::fs::Permissions::from_mode(0o755))?; - } - Ok(()) - })() { - tracing::warn!("Failed to seed agent from bundle: {e}"); - } else { - tracing::info!("Seeded arcbox-agent from bundle"); + match copy_if_changed(agent_src, &agent_dst) { + Ok(true) => tracing::info!("Seeded arcbox-agent from bundle"), + Ok(false) => tracing::debug!("arcbox-agent already up to date"), + Err(e) => return Err(e).context("failed to copy bundle arcbox-agent"), + } + Ok(AgentSeed::Bundle) +} + +/// Copies `src` to `dst` when `dst` is missing or differs from `src` by size or +/// modification time, returning whether a copy occurred. +/// +/// The source mtime is mirrored onto the destination so a subsequent run can +/// recognise an up-to-date copy with a cheap `stat` instead of reading file +/// contents — important because the runtime tree is hundreds of MB and seeding +/// runs on every daemon start. Replacements are written to a temporary file in +/// the destination directory and then atomically renamed into place, so a killed +/// daemon cannot leave a partially-written staged binary at `dst`. +fn copy_if_changed(src: &Path, dst: &Path) -> std::io::Result { + let src_meta = std::fs::metadata(src)?; + let src_mtime = filetime::FileTime::from_last_modification_time(&src_meta); + if let Ok(dst_meta) = std::fs::metadata(dst) { + if dst_meta.len() == src_meta.len() + && filetime::FileTime::from_last_modification_time(&dst_meta) == src_mtime + { + return Ok(false); } } + copy_atomic(src, dst, src_mtime)?; + Ok(true) } -/// Copies specific files from `src` to `dst`, skipping those already present. +fn copy_atomic(src: &Path, dst: &Path, src_mtime: filetime::FileTime) -> std::io::Result<()> { + let parent = dst.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + + let tmp = temp_path_for(dst); + let result = (|| -> std::io::Result<()> { + std::fs::copy(src, &tmp)?; + filetime::set_file_mtime(&tmp, src_mtime)?; + std::fs::rename(&tmp, dst)?; + Ok(()) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +fn temp_path_for(dst: &Path) -> PathBuf { + let parent = dst.parent().unwrap_or_else(|| Path::new(".")); + let file_name = dst + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("copy"); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), nonce)) +} + +/// Copies specific files from `src` to `dst`, refreshing any that changed. fn seed_dir_files(src: &Path, dst: &Path, files: &[&str], label: &str) { - if let Err(e) = (|| -> std::io::Result<()> { + let result = (|| -> std::io::Result { + let mut count = 0u32; std::fs::create_dir_all(dst)?; for name in files { let s = src.join(name); - let d = dst.join(name); - if s.exists() && !d.exists() { - std::fs::copy(&s, &d)?; + if s.exists() && copy_if_changed(&s, &dst.join(name))? { + count += 1; } } - Ok(()) - })() { - tracing::warn!("Failed to seed {label} from bundle: {e}"); - } else { - tracing::info!("Seeded {label} from bundle"); + Ok(count) + })(); + + match result { + Ok(0) => tracing::debug!("{label}: already up to date"), + Ok(n) => tracing::info!("Seeded {n} {label} from bundle"), + Err(e) => tracing::warn!("Failed to seed {label} from bundle: {e}"), } } -/// Recursively copies a directory tree, skipping files already present. +/// Recursively copies a directory tree, refreshing any files that changed. fn seed_dir_recursive(src: &Path, dst: &Path, label: &str) { let result = (|| -> std::io::Result { let mut count = 0u32; @@ -119,22 +257,7 @@ fn seed_dir_recursive(src: &Path, dst: &Path, label: &str) { let d = dst.join(&rel); if is_dir { std::fs::create_dir_all(&d)?; - } else if !d.exists() { - if let Some(parent) = d.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(src.join(&rel), &d)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - // Preserve source executable bits without broadening. - let src_mode = std::fs::metadata(src.join(&rel))?.permissions().mode(); - if src_mode & 0o111 != 0 { - let dst_mode = std::fs::metadata(&d)?.permissions().mode(); - let new_mode = (dst_mode & !0o111) | (src_mode & 0o111); - std::fs::set_permissions(&d, std::fs::Permissions::from_mode(new_mode))?; - } - } + } else if copy_if_changed(&src.join(&rel), &d)? { count += 1; } } @@ -142,7 +265,7 @@ fn seed_dir_recursive(src: &Path, dst: &Path, label: &str) { })(); match result { - Ok(0) => tracing::debug!("{label}: already seeded"), + Ok(0) => tracing::debug!("{label}: already up to date"), Ok(n) => tracing::info!("Seeded {n} {label} from bundle"), Err(e) => tracing::warn!("Failed to seed {label} from bundle: {e}"), } @@ -213,13 +336,32 @@ pub(super) async fn ensure_boot_assets( Ok(()) } -/// Copies the arcbox-agent binary from the boot asset cache to the daemon's -/// bin directory if it is not already present. -pub(super) fn ensure_agent_binary(data_dir: &Path) -> Result<()> { - let agent_dest = data_dir.join("bin/arcbox-agent"); - if agent_dest.exists() { - return Ok(()); +/// Installs the boot-cache agent only when bundle seeding did not provide one. +/// +/// This fallback path is only used when the daemon is not running from an app +/// bundle or the bundle does not contain an agent. Bundle-provided agents are +/// authoritative and must not be overwritten by a possibly older boot cache. +pub(super) fn ensure_agent_binary_fallback( + data_dir: &Path, + bundle_seed: BundleSeed, +) -> Result { + if !bundle_seed.needs_agent_fallback() { + return Ok(PreparedAgent::Bundle); + } + + if ensure_agent_binary(data_dir)? { + Ok(PreparedAgent::BootCache) + } else { + Ok(PreparedAgent::Unavailable) } +} + +/// Installs the arcbox-agent binary from the downloaded boot cache. +/// +/// Call [`ensure_agent_binary_fallback`] from startup code so bundle-provided +/// agents keep precedence over the boot cache. +pub(super) fn ensure_agent_binary(data_dir: &Path) -> Result { + let agent_dest = data_dir.join("bin/arcbox-agent"); let version = arcbox_core::boot_asset_version(); let agent_src = data_dir.join(format!("boot/{version}/arcbox-agent")); @@ -228,20 +370,117 @@ pub(super) fn ensure_agent_binary(data_dir: &Path) -> Result<()> { "Agent binary not found in boot cache at {}", agent_src.display() ); - return Ok(()); + return Ok(false); } - std::fs::create_dir_all(agent_dest.parent().unwrap()) - .context("Failed to create agent bin directory")?; - std::fs::copy(&agent_src, &agent_dest).context("Failed to copy agent binary")?; + if copy_if_changed(&agent_src, &agent_dest).context("Failed to install agent binary")? { + info!("Agent binary installed from boot cache"); + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::{ + AgentSeed, BundleSeed, PreparedAgent, copy_if_changed, ensure_agent_binary_fallback, + seed_agent_from_bundle, + }; + use std::fs; + + #[test] + fn copies_when_missing_then_skips_when_identical() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let dst = dir.path().join("nested/dst"); + fs::write(&src, b"v1").unwrap(); + + // Destination missing → copied. + assert!(copy_if_changed(&src, &dst).unwrap()); + assert_eq!(fs::read(&dst).unwrap(), b"v1"); + + // Unchanged source → skipped (mtime mirrored on the first copy). + assert!(!copy_if_changed(&src, &dst).unwrap()); + } + + #[test] + fn recopies_when_source_content_changes() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let dst = dir.path().join("dst"); + fs::write(&src, b"v1").unwrap(); + assert!(copy_if_changed(&src, &dst).unwrap()); + + // A different build differs in size and/or mtime → refreshed. + fs::write(&src, b"v2-larger").unwrap(); + let newer = filetime::FileTime::from_unix_time( + filetime::FileTime::from_last_modification_time(&fs::metadata(&src).unwrap()) + .unix_seconds() + + 5, + 0, + ); + filetime::set_file_mtime(&src, newer).unwrap(); + assert!(copy_if_changed(&src, &dst).unwrap()); + assert_eq!(fs::read(&dst).unwrap(), b"v2-larger"); + } #[cfg(unix)] - { + #[test] + fn preserves_executable_bit() { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&agent_dest, std::fs::Permissions::from_mode(0o755)) - .context("Failed to set agent binary permissions")?; + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("bin"); + let dst = dir.path().join("bin-copy"); + fs::write(&src, b"#!/bin/sh\n").unwrap(); + fs::set_permissions(&src, fs::Permissions::from_mode(0o755)).unwrap(); + + copy_if_changed(&src, &dst).unwrap(); + + let mode = fs::metadata(&dst).unwrap().permissions().mode(); + assert_ne!(mode & 0o111, 0, "executable bit should be preserved"); } - info!("Agent binary installed from boot cache"); - Ok(()) + #[test] + fn boot_cache_installs_agent_when_bundle_agent_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let cached_agent = dir.path().join(format!( + "boot/{}/arcbox-agent", + arcbox_core::boot_asset_version() + )); + fs::create_dir_all(cached_agent.parent().unwrap()).unwrap(); + fs::write(&cached_agent, b"boot-cache-agent").unwrap(); + + let agent = ensure_agent_binary_fallback(dir.path(), BundleSeed::default()).unwrap(); + assert_eq!(agent, PreparedAgent::BootCache); + + let staged_agent = dir.path().join("bin/arcbox-agent"); + assert_eq!(fs::read(staged_agent).unwrap(), b"boot-cache-agent"); + } + + #[test] + fn boot_cache_does_not_overwrite_bundle_agent() { + let dir = tempfile::tempdir().unwrap(); + let bundle_agent = dir.path().join("bundle-agent"); + fs::write(&bundle_agent, b"bundle-agent").unwrap(); + let agent = seed_agent_from_bundle(&bundle_agent, dir.path()).unwrap(); + assert_eq!(agent, AgentSeed::Bundle); + + let cached_agent = dir.path().join(format!( + "boot/{}/arcbox-agent", + arcbox_core::boot_asset_version() + )); + fs::create_dir_all(cached_agent.parent().unwrap()).unwrap(); + fs::write(&cached_agent, b"older-boot-cache-agent").unwrap(); + + let agent = ensure_agent_binary_fallback( + dir.path(), + BundleSeed { + agent: AgentSeed::Bundle, + }, + ) + .unwrap(); + assert_eq!(agent, PreparedAgent::Bundle); + + let staged_agent = dir.path().join("bin/arcbox-agent"); + assert_eq!(fs::read(staged_agent).unwrap(), b"bundle-agent"); + } } diff --git a/app/arcbox-daemon/src/startup/mod.rs b/app/arcbox-daemon/src/startup/mod.rs index 8d22e430b..68d49bafb 100644 --- a/app/arcbox-daemon/src/startup/mod.rs +++ b/app/arcbox-daemon/src/startup/mod.rs @@ -3,9 +3,11 @@ mod assets; mod cleanup; mod lock; +mod pipeline; pub use assets::find_bundle_contents; pub use lock::DaemonLock; +pub use pipeline::Startup; use std::path::PathBuf; use std::sync::Arc; @@ -29,7 +31,7 @@ const DEFAULT_DNS_DOMAIN: &str = "arcbox.local"; /// Returns an [`EarlyContext`] sufficient to start the gRPC /// SystemService so clients can observe the full startup progression. /// Call [`acquire_lock`] next to obtain a [`DaemonContext`]. -pub async fn init_early(args: DaemonArgs) -> Result { +async fn init_early(args: DaemonArgs) -> Result { let setup_state = Arc::new(SetupState::new()); let mut layout = HostLayout::resolve(args.data_dir.as_deref()); @@ -73,7 +75,7 @@ pub async fn init_early(args: DaemonArgs) -> Result { /// blocks on `flock(LOCK_EX)` until the lock is released. Must run /// before `start_grpc` because the old daemon may still be listening /// on the same socket paths. -pub async fn acquire_lock(early: EarlyContext) -> Result { +async fn acquire_lock(early: EarlyContext) -> Result { let lock_file = early.layout.lock_file.clone(); let lock = tokio::task::spawn_blocking(move || DaemonLock::acquire(&lock_file)) .await @@ -105,7 +107,7 @@ pub async fn acquire_lock(early: EarlyContext) -> Result { /// /// On non-macOS this is a no-op (no XPC helpers). #[cfg(target_os = "macos")] -pub async fn wait_for_resources(ctx: &DaemonContext) -> Result<()> { +async fn wait_for_resources(ctx: &DaemonContext) -> Result<()> { let candidates = ["docker.img", "docker-rosetta.img"]; let docker_imgs: Vec = candidates .iter() @@ -137,16 +139,28 @@ pub async fn wait_for_resources(ctx: &DaemonContext) -> Result<()> { /// No-op on non-macOS — no Virtualization.framework XPC helpers. #[cfg(not(target_os = "macos"))] -pub async fn wait_for_resources(_ctx: &DaemonContext) -> Result<()> { +async fn wait_for_resources(_ctx: &DaemonContext) -> Result<()> { Ok(()) } +/// Reconciles bundle, downloaded, and staged assets for this startup. +/// +/// Called after gRPC is already listening so clients can observe download +/// progress and before [`init_runtime`] so the runtime sees coherent artifacts +/// for the launched app version. +async fn prepare_assets(ctx: &DaemonContext) -> Result { + let prepared = assets::prepare(&ctx.layout.data_dir, &ctx.setup_state).await?; + ctx.setup_state + .set_phase(SetupPhase::AssetsReady, "Boot assets ready"); + Ok(prepared) +} + /// Phase 2: seed/download boot assets, build runtime, start VM. /// /// Called after gRPC SystemService is already listening so clients /// can observe DOWNLOADING_ASSETS → ASSETS_READY progression. /// Returns the initialized runtime. -pub async fn init_runtime(ctx: &DaemonContext) -> Result> { +async fn init_runtime(ctx: &DaemonContext) -> Result> { let mut config = Config::load().unwrap_or_else(|err| { warn!(error = %err, "Failed to load config file; falling back to defaults"); Config::default() @@ -165,21 +179,6 @@ pub async fn init_runtime(ctx: &DaemonContext) -> Result> { config.vm.kernel_path = Some(kernel.clone()); } - // Seed boot assets from app bundle if available, otherwise download. - // Run on a blocking thread to avoid stalling the async runtime during - // large file copies (kernel + rootfs + runtime binaries). - let data_dir = ctx.layout.data_dir.clone(); - tokio::task::spawn_blocking(move || { - assets::seed_from_bundle(&data_dir); - assets::ensure_agent_binary(&data_dir) - }) - .await - .context("seed task panicked")? - .context("seed failed")?; - assets::ensure_boot_assets(&ctx.layout.data_dir, &ctx.setup_state).await?; - ctx.setup_state - .set_phase(SetupPhase::AssetsReady, "Boot assets ready"); - let runtime = Arc::new(Runtime::new(config).context("Failed to create runtime")?); runtime .init() diff --git a/app/arcbox-daemon/src/startup/pipeline.rs b/app/arcbox-daemon/src/startup/pipeline.rs new file mode 100644 index 000000000..c57a7122e --- /dev/null +++ b/app/arcbox-daemon/src/startup/pipeline.rs @@ -0,0 +1,175 @@ +//! Typed daemon startup pipeline. +//! +//! Each step consumes the previous state and returns the next one, so `main.rs` +//! can show the startup order directly while Rust types enforce it. + +use std::sync::Arc; + +use anyhow::Result; +use arcbox_api::SetupPhase; +use arcbox_core::Runtime; +use macos_resolver::FileResolver; +use tracing::info; + +use crate::context::{DaemonContext, EarlyContext, ServiceHandles}; +use crate::{DaemonArgs, recovery, services}; + +use super::{acquire_lock, assets, init_early, init_runtime, prepare_assets, wait_for_resources}; + +/// Entry point for the daemon startup lifecycle. +pub struct Startup { + args: DaemonArgs, +} + +pub struct ReadyDaemon { + pub ctx: DaemonContext, + pub handles: ServiceHandles, +} + +impl Startup { + /// Creates a startup pipeline from parsed daemon arguments. + pub fn from_args(args: DaemonArgs) -> Self { + Self { args } + } + + /// Prepares host directories and pre-lock context. + pub async fn prepare_host(self) -> Result { + info!("Starting ArcBox daemon..."); + let early = init_early(self.args).await?; + Ok(HostPrepared { early }) + } +} + +pub struct HostPrepared { + early: EarlyContext, +} + +impl HostPrepared { + /// Acquires the exclusive daemon lease. + pub async fn acquire_daemon_lease(self) -> Result { + let ctx = acquire_lock(self.early).await?; + Ok(DaemonLeased { ctx }) + } +} + +pub struct DaemonLeased { + ctx: DaemonContext, +} + +impl DaemonLeased { + /// Starts gRPC before slow runtime phases so clients can observe progress. + pub async fn start_control_plane(self) -> Result { + let shared_runtime = Arc::clone(&self.ctx.shared_runtime); + let grpc = services::start_grpc(&self.ctx, shared_runtime).await?; + Ok(ControlPlaneStarted { + ctx: self.ctx, + grpc, + }) + } +} + +pub struct ControlPlaneStarted { + ctx: DaemonContext, + grpc: tokio::task::JoinHandle<()>, +} + +impl ControlPlaneStarted { + /// Waits for stale resource holders from a previous daemon to release. + pub async fn release_stale_resources(self) -> Result { + wait_for_resources(&self.ctx).await?; + Ok(ResourcesReleased { + ctx: self.ctx, + grpc: self.grpc, + }) + } +} + +pub struct ResourcesReleased { + ctx: DaemonContext, + grpc: tokio::task::JoinHandle<()>, +} + +impl ResourcesReleased { + /// Reconciles bundle, downloaded, and staged assets for this app version. + pub async fn prepare_assets(self) -> Result { + let assets = prepare_assets(&self.ctx).await?; + info!(agent = ?assets.agent(), "Startup assets prepared"); + Ok(AssetsPrepared { + ctx: self.ctx, + grpc: self.grpc, + _assets: assets, + }) + } +} + +pub struct AssetsPrepared { + ctx: DaemonContext, + grpc: tokio::task::JoinHandle<()>, + _assets: assets::PreparedAssets, +} + +impl AssetsPrepared { + /// Builds and initializes the ArcBox runtime. + pub async fn boot_runtime(self) -> Result { + let runtime = init_runtime(&self.ctx).await?; + Ok(RuntimeBooted { + ctx: self.ctx, + grpc: self.grpc, + runtime, + }) + } +} + +pub struct RuntimeBooted { + ctx: DaemonContext, + grpc: tokio::task::JoinHandle<()>, + runtime: Arc, +} + +impl RuntimeBooted { + /// Starts services that require an initialized runtime. + pub async fn start_runtime_services(self) -> Result { + let handles = services::start_services(&self.ctx, &self.runtime, self.grpc).await?; + recovery::run(&self.ctx, &self.runtime).await; + Ok(RuntimeServicesStarted { + ctx: self.ctx, + handles, + }) + } +} + +pub struct RuntimeServicesStarted { + ctx: DaemonContext, + handles: ServiceHandles, +} + +impl RuntimeServicesStarted { + /// Marks startup complete and returns handles for the shutdown loop. + pub async fn mark_ready(self) -> Result { + check_resolver_installed(&self.ctx.dns_domain); + self.ctx + .setup_state + .set_phase(SetupPhase::Ready, "Daemon ready"); + + println!("ArcBox daemon started"); + println!(" Docker API: {}", self.ctx.layout.docker_socket.display()); + println!(" gRPC API: {}", self.ctx.layout.grpc_socket.display()); + println!(" DNS: 127.0.0.1:{}", self.ctx.dns_port); + println!(" Data: {}", self.ctx.layout.data_dir.display()); + println!(); + println!("Use 'arcbox docker enable' to configure Docker CLI integration."); + println!("Press Ctrl+C to stop."); + + Ok(ReadyDaemon { + ctx: self.ctx, + handles: self.handles, + }) + } +} + +fn check_resolver_installed(domain: &str) { + let resolver = FileResolver::new("arcbox"); + if !resolver.is_registered(domain) { + println!("Hint: Run 'sudo arcbox dns install' to enable *.{domain} DNS resolution."); + } +}