Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 8 additions & 2 deletions app/arcbox-cli/src/commands/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 30 additions & 11 deletions app/arcbox-core/src/agent_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -344,13 +344,20 @@ 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 {} \
(agent version {}), daemon requires >= {}. The staged agent \
(agent version {}, response {:?}), daemon requires >= {}. The staged agent \
Comment thread
pullfrog[bot] marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -1967,12 +1974,24 @@ 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]
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"));
assert!(err.to_string().contains("stale"));
}

#[test]
Expand Down
11 changes: 11 additions & 0 deletions app/arcbox-core/src/boot_assets/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct BootAssetConfig {
pub cache_dir: PathBuf,
/// Custom kernel path (skip download).
pub custom_kernel: Option<PathBuf>,
/// Allow a locally built development manifest to differ from `assets.lock`.
pub allow_unpinned_manifest: bool,
}

impl Default for BootAssetConfig {
Expand All @@ -38,6 +40,7 @@ impl Default for BootAssetConfig {
.join(".arcbox")
.join("boot"),
custom_kernel: None,
allow_unpinned_manifest: false,
}
}
}
Expand All @@ -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 {
Expand Down
101 changes: 61 additions & 40 deletions app/arcbox-core/src/boot_assets/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// Kernel command line.
pub cmdline: String,
/// Asset version.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 \
Expand All @@ -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::<BootAssetManifest>(&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).
Expand Down Expand Up @@ -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<bool> {
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<Vec<String>> {
let cache_dir = &self.config.cache_dir;
Expand Down Expand Up @@ -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"));
}
}
48 changes: 48 additions & 0 deletions app/arcbox-core/src/boot_assets/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
// SAFETY: Test code running under ENV_LOCK mutex.
unsafe {
Expand Down
Loading
Loading