diff --git a/src/commands/vm/update.rs b/src/commands/vm/update.rs index c03ab45a..e350977f 100644 --- a/src/commands/vm/update.rs +++ b/src/commands/vm/update.rs @@ -6,11 +6,31 @@ //! //! - `replace` — always re-downloaded when the sha differs (kernel, //! initramfs, rootfs). -//! - `seed_only` — downloaded only on first install. On subsequent -//! updates we skip it entirely so the user's `var` (Docker volumes, -//! project caches in `/data`, the persistent `var.btrfs`) survives -//! across image bumps. Refresh via `avocado vm reset-var` if you -//! actually want a clean slate. +//! - `seed_only` — the `var` image. Fetched on first install and, on a +//! version bump, re-fetched and re-seeded. See below. +//! +//! ## Why a version bump resets `var` +//! +//! `seed_only` was written to preserve user state across image bumps, +//! on the assumption that the in-VM agent would migrate the existing +//! btrfs at boot. That +//! migration does not exist yet, and `var` holds more than user state: +//! the VM's own system extensions live in `/var/lib/avocado/images` and +//! are merged onto `/usr` and `/etc` at boot. Carrying `var` across a +//! bump therefore boots a new kernel and rootfs against the *old* +//! userspace. +//! +//! Nothing catches that. Each extension's `extension-release` is +//! synthesized at merge time carrying `ID=_any`, which is +//! systemd-sysext's explicit "matches any OS" wildcard — so the +//! mismatched extensions merge silently and the VM looks healthy. +//! +//! Until the guest can migrate in place, a version bump takes the new +//! seed and discards the live disk. That costs the user their Docker +//! volumes, SDK images and `/data` work, so they must re-run `avocado +//! install` and `avocado build` — a tradeoff deliberately accepted over +//! shipping a VM whose halves don't match. An unchanged `var` sha means +//! no reset. //! //! Behaviour with a running VM: query lifecycle, stop it cleanly, //! perform the swap, restart with the same `start` options. The @@ -33,13 +53,17 @@ use crate::utils::vm::channel::ChannelPointer; use crate::utils::vm::manifest::{Manifest, UpdatePolicy}; use crate::utils::vm::staging::StagingDir; use crate::utils::vm::state::VmPaths; -use crate::utils::vm_update_check::{check_for_vm_update, DEFAULT_BASE}; +use crate::utils::vm_update_check::{check_for_vm_update, VmUpdateStatus, DEFAULT_BASE}; /// CLI surface — keep this in sync with the clap variant in main.rs. pub struct UpdateCommand { pub channel: Option, pub check_only: bool, pub assume_yes: bool, + /// Authorizes the var reset when combined with `--yes`. Interactive + /// runs consent at the typed prompt instead, so this is only + /// consulted when `assume_yes` is set. + pub reset_var: bool, pub output: OutputFormat, } @@ -73,14 +97,35 @@ impl UpdateCommand { .and_then(|m| m.version.as_deref()) .map(|s| s.to_string()); - // Channel poll (24h cached). Returns Some only when newer - // *and* CLI is compatible with min_cli_version. - let avail = check_for_vm_update(&channel_name, installed_version.as_deref()).await; - - // Print the "what's available" summary so --check is useful - // even when nothing's new. - let Some(avail) = avail else { - return print_up_to_date(installed_version.as_deref(), &channel_name, self.output); + // Channel poll (24h cached). + let avail = match check_for_vm_update(&channel_name, installed_version.as_deref()).await { + VmUpdateStatus::Available(avail) => avail, + // A newer release exists that this CLI must not install. + // Fail rather than print-and-succeed: host applications map + // a non-zero `--check` exit to an error status carrying our + // stderr, so the actionable "run `avocado upgrade` first" + // message reaches their UI without them needing to + // understand a new field. In JSON mode a structured line + // goes out first so stdout parsers can tell "blocked on CLI + // version" from "check broke". + VmUpdateStatus::CliTooOld { message, remote } => { + if self.output.is_json() { + crate::utils::output_format::emit_json_object(&json!({ + "channel": channel_name, + "installed": installed_version, + "remote": remote, + "update_available": true, + "cli_too_old": true, + "message": message, + })); + } + bail!(message) + } + // Print the "what's available" summary so --check is useful + // even when nothing's new. + VmUpdateStatus::NoUpdate => { + return print_up_to_date(installed_version.as_deref(), &channel_name, self.output) + } }; if self.check_only { @@ -113,11 +158,6 @@ impl UpdateCommand { ) })?; - // Confirm with the user (unless --yes). - if !self.assume_yes { - confirm(&avail.pointer, installed_version.as_deref())?; - } - // HTTP client — `connect_timeout` is bounded so a stalled DNS / // TCP handshake fails fast, but the overall request timeout is // unset because artifact downloads can run several minutes on @@ -129,21 +169,25 @@ impl UpdateCommand { .connect_timeout(Duration::from_secs(30)) .pool_idle_timeout(Some(Duration::from_secs(60))) .build()?; - let new_manifest: Manifest = serde_json::from_str( - &http - .get(&platform_entry.manifest_url) - .send() - .await? - .error_for_status()? - .text() - .await?, - ) - .context("parsing remote manifest")?; - - // Decide what to download. `seed_only` artifacts are pulled - // only when the installed dir has no copy of them (first run); - // on a real update they're skipped entirely. `replace` - // artifacts are pulled whenever the sha differs. + // Fetched once and kept: we parse this text to plan the download + // and later write the same bytes out as the installed manifest. + // Two separate GETs could disagree if the release were + // re-published mid-run, leaving a manifest that doesn't describe + // the artifacts we actually committed. + let manifest_raw = http + .get(&platform_entry.manifest_url) + .send() + .await? + .error_for_status()? + .text() + .await?; + let new_manifest: Manifest = + serde_json::from_str(&manifest_raw).context("parsing remote manifest")?; + + // Decide what to download. `replace` artifacts are pulled + // whenever the sha differs; the `seed_only` var image is pulled + // on first install, on any sha change, and when the seed file + // itself is missing (see the module docs). let install_dir = paths.install_dir(); std::fs::create_dir_all(&install_dir) .with_context(|| format!("creating install dir {}", install_dir.display()))?; @@ -153,6 +197,41 @@ impl UpdateCommand { return Ok(()); } + let reseed_var = + should_reseed_var(installed.as_ref(), &new_manifest, paths.var_disk().exists()); + + // `--yes` alone consents to a non-destructive update only; the + // var reset needs `--reset-var` (interactive runs consent at + // the typed prompt instead). + if reseed_var && self.assume_yes && !self.reset_var { + bail!( + "this update ships a new /var image and resets the VM's internal \ + state (installed SDKs, Docker volumes, /data). Re-run with \ + `--yes --reset-var` to authorize, or without `--yes` to be \ + prompted." + ); + } + + // Confirm with the user (unless --yes). Deliberately after + // planning: whether this destroys the VM's state is the material + // fact about the operation, and we can't know it before reading + // the remote manifest. + if !self.assume_yes { + // The prompt writes prose into the NDJSON stream and blocks + // on stdin; machine mode never prompts. + if self.output.is_json() { + bail!( + "refusing to prompt in --output json mode; re-run with --yes{}", + if reseed_var { + " --reset-var (this update resets the VM's /var)" + } else { + "" + } + ); + } + confirm(&avail.pointer, installed_version.as_deref(), reseed_var)?; + } + // Was the VM running before we tear it down? let was_running = is_vm_running().await; @@ -230,19 +309,55 @@ impl UpdateCommand { format!("committing {} into {}", item.file, install_dir.display()) })?; } + // Replace the live var disk once the new seed is committed and + // the VM is down. Deleting rather than copying is deliberate: + // `lifecycle::start` already re-seeds a missing var.btrfs from + // the artifact dir and then re-applies the configured size, so + // the boot path stays the single owner of both, and an update + // that dies here leaves no half-copied disk to mistake for + // state — the next start just seeds it. + if reseed_var { + let var = paths.var_disk(); + if var.exists() { + // The live disk's length is the only record of how far + // the user grew /var (growing never wrote config). + // Persist it before the delete so the re-seeded disk + // comes back at the same capacity. + let live_len = std::fs::metadata(&var) + .with_context(|| format!("stat {}", var.display()))? + .len(); + let mut cfg = crate::utils::vm::config::VmConfig::load(&paths) + .context("loading config.yaml to record the var size")?; + if record_var_size_floor(&mut cfg, live_len)? { + cfg.save(&paths) + .context("recording runtime.var_size in config.yaml")?; + } + std::fs::remove_file(&var) + .with_context(|| format!("removing stale var disk {}", var.display()))?; + } + if json_mode { + // Host applications need this to know the VM's SDK and + // Docker state is gone, so they can drop cached install + // state and tell the user to re-run install/build. + crate::utils::output_format::emit_json_object(&json!({ + "event": "var_reset", + "reason": "vm_image_updated", + })); + } else { + println!( + "avocado vm update: reset /var to the new seed; \ + re-run `avocado install` and `avocado build`." + ); + } + } + // Write the new manifest last — it's the marker that says // "this install is complete at this version." let manifest_path = install_dir.join("manifest.json"); - let manifest_bytes = - serde_json::to_vec_pretty(&serde_json::from_str::( - &http - .get(&platform_entry.manifest_url) - .send() - .await? - .error_for_status()? - .text() - .await?, - )?)?; + let manifest_bytes = serde_json::to_vec_pretty( + &serde_json::from_str::(&manifest_raw) + .context("re-parsing remote manifest for the install dir")?, + )?; std::fs::write(&manifest_path, &manifest_bytes) .with_context(|| format!("writing {}", manifest_path.display()))?; @@ -288,8 +403,7 @@ struct PlannedDownload { size: Option, } -/// Decide what to download from the new manifest. Skips `seed_only` -/// artifacts when an installed copy exists. +/// Decide what to download from the new manifest. fn plan_downloads( new: &Manifest, installed: Option<&Manifest>, @@ -297,36 +411,88 @@ fn plan_downloads( ) -> Vec { let mut out = Vec::new(); for (role, art) in &new.artifacts { - match art.update_policy { - UpdatePolicy::SeedOnly => { - // Pull only on first install (no existing file in - // install_dir for this role's filename). - if !install_dir.join(&art.file).exists() { - out.push(PlannedDownload { - file: art.file.clone(), - sha256: art.sha256.clone(), - size: art.size, - }); - } - } - UpdatePolicy::Replace => { - // Pull if installed sha differs (or no installed manifest yet). - let installed_sha = installed - .and_then(|m| m.artifact(role)) - .map(|a| a.sha256.as_str()); - if installed_sha != Some(art.sha256.as_str()) { - out.push(PlannedDownload { - file: art.file.clone(), - sha256: art.sha256.clone(), - size: art.size, - }); - } - } + // Sha comparison against the installed manifest, for both + // policies. `None` (no installed manifest — first install) + // never matches, so everything is fetched. + let installed_sha = installed + .and_then(|m| m.artifact(role)) + .map(|a| a.sha256.as_str()); + let sha_differs = installed_sha != Some(art.sha256.as_str()); + let wanted = match art.update_policy { + // On a first install, the file's presence is the only signal + // we have — there's no installed manifest to compare against, + // and a var image already in place is one we just fetched. + // On an update the sha decides, so an unchanged var image + // costs neither a ~450 MB download nor the user's state. + // A missing seed is re-fetched regardless of sha: without a + // source `seed_var_disk` no-ops and the VM boots with no + // /var, unrepaired by any later update. + UpdatePolicy::SeedOnly => match installed { + Some(_) => sha_differs || !install_dir.join(&art.file).exists(), + None => !install_dir.join(&art.file).exists(), + }, + UpdatePolicy::Replace => sha_differs, + }; + if wanted { + out.push(PlannedDownload { + file: art.file.clone(), + sha256: art.sha256.clone(), + size: art.size, + }); } } out } +/// Whether this run replaces the live var disk: +/// +/// - an installed manifest exists (a first install has no state to +/// destroy), +/// - the live disk exists (a never-started VM has nothing to reset, +/// and the prompt + `var_reset` event must not fire for one), and +/// - a `seed_only` artifact's sha changed. Keyed on the manifests, not +/// the planned downloads: `plan_downloads` also re-fetches a seed +/// that merely went missing (same sha), which must not reset the +/// live disk. +fn should_reseed_var(installed: Option<&Manifest>, new: &Manifest, var_disk_exists: bool) -> bool { + let Some(installed) = installed else { + return false; + }; + if !var_disk_exists { + return false; + } + new.artifacts.iter().any(|(role, art)| { + art.update_policy == UpdatePolicy::SeedOnly + && installed.artifact(role).map(|a| a.sha256.as_str()) != Some(art.sha256.as_str()) + }) +} + +/// Raise `runtime.var_size` to cover a live disk of `live_len` bytes, +/// rounded up to whole GiB. Returns whether the config changed. The +/// effective size never shrinks (`grow_var_file` refuses to), so the +/// live disk is always >= any previously configured target; recording +/// its length preserves the largest size the user ever grew to. +fn record_var_size_floor( + cfg: &mut crate::utils::vm::config::VmConfig, + live_len: u64, +) -> Result { + use crate::utils::vm::lifecycle::{parse_size, DEFAULT_VAR_SIZE}; + + let configured = cfg + .runtime + .as_ref() + .and_then(|r| r.var_size.as_deref()) + .unwrap_or(DEFAULT_VAR_SIZE); + let configured_bytes = parse_size(configured) + .with_context(|| format!("invalid runtime.var_size {configured:?} in config.yaml"))?; + if live_len <= configured_bytes { + return Ok(false); + } + let gib = live_len.div_ceil(1 << 30); + cfg.runtime.get_or_insert_with(Default::default).var_size = Some(format!("{gib}G")); + Ok(true) +} + /// Best guess at the host's platform string. Matches what the avocado-vm /// stone generator emits. fn default_platform_for_host() -> String { @@ -345,9 +511,37 @@ async fn is_vm_running() -> bool { .unwrap_or(false) } -fn confirm(p: &ChannelPointer, installed: Option<&str>) -> Result<()> { +fn confirm(p: &ChannelPointer, installed: Option<&str>, reseed_var: bool) -> Result<()> { let from = installed.unwrap_or("(not installed)"); println!("avocado vm update: {} -> {}", from, p.version); + if reseed_var { + // Spelled out, and gated behind typing a word rather than `y`, + // because this is not the non-destructive update the command name + // implies. Matches `avocado vm reset`'s prompt for the same reason. + println!(); + println!("This release ships a new /var image, which carries the VM's own"); + println!("system extensions, so /var is replaced rather than migrated. The"); + println!("update resets the VM's internal state:"); + println!(); + println!(" - installed SDKs and their container images"); + println!(" - Docker volumes and build caches"); + println!(" - anything resident in /data inside the VM"); + println!(); + println!("Your projects on the host are untouched, but each will need"); + println!("`avocado install` and `avocado build` run again afterwards."); + println!(); + print!("Type 'update' to confirm: "); + use std::io::Write; + std::io::stdout().flush().ok(); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("reading confirmation")?; + if line.trim() != "update" { + bail!("aborted by user"); + } + return Ok(()); + } print!("Proceed? [y/N] "); use std::io::Write; std::io::stdout().flush().ok(); @@ -492,3 +686,186 @@ async fn download_artifact( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A manifest with one `replace` artifact (rootfs) and the + /// `seed_only` var image, at caller-chosen shas. + fn manifest_json(rootfs_sha: &str, var_sha: &str) -> String { + format!( + r#"{{ + "format": "avocado-direct", + "format_version": 1, + "version": "0.4.0", + "platform": "avocado-qemuarm64", + "architecture": "arm64", + "artifacts": {{ + "rootfs": {{ "file": "rootfs.erofs-lz4", "sha256": "{rootfs_sha}", + "type": "erofs-lz4", "update_policy": "replace" }}, + "var": {{ "file": "var.btrfs", "sha256": "{var_sha}", + "type": "btrfs", "update_policy": "seed_only" }} + }}, + "cmdline_default": "" + }}"# + ) + } + + fn manifest(rootfs_sha: &str, var_sha: &str) -> Manifest { + serde_json::from_str(&manifest_json(rootfs_sha, var_sha)).expect("fixture parses") + } + + fn planned(downloads: &[PlannedDownload], file: &str) -> bool { + downloads.iter().any(|d| d.file == file) + } + + #[test] + fn first_install_fetches_every_artifact() { + let dir = tempfile::tempdir().unwrap(); + let plan = plan_downloads(&manifest("aa", "bb"), None, dir.path()); + assert!(planned(&plan, "rootfs.erofs-lz4")); + assert!(planned(&plan, "var.btrfs")); + } + + #[test] + fn first_install_skips_a_var_image_already_on_disk() { + // Resuming a first install that already pulled the ~450 MB var + // image: no installed manifest to compare shas against, so + // presence is the only signal. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("var.btrfs"), b"seed").unwrap(); + let plan = plan_downloads(&manifest("aa", "bb"), None, dir.path()); + assert!(planned(&plan, "rootfs.erofs-lz4")); + assert!(!planned(&plan, "var.btrfs")); + } + + #[test] + fn update_with_a_new_var_image_plans_it() { + let dir = tempfile::tempdir().unwrap(); + // The var image is on disk from the previous install — under the + // old rule that alone was enough to skip it, which is exactly how + // a stale /var survived a version bump. + std::fs::write(dir.path().join("var.btrfs"), b"old seed").unwrap(); + let installed = manifest("aa", "bb"); + let plan = plan_downloads(&manifest("aa2", "bb2"), Some(&installed), dir.path()); + assert!(planned(&plan, "var.btrfs")); + } + + #[test] + fn update_leaves_an_unchanged_var_image_alone() { + // A release that only bumps the boot artifacts must not cost the + // user their /var — nothing in it is stale. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("var.btrfs"), b"seed").unwrap(); + let installed = manifest("aa", "bb"); + let plan = plan_downloads(&manifest("aa2", "bb"), Some(&installed), dir.path()); + assert!(planned(&plan, "rootfs.erofs-lz4")); + assert!(!planned(&plan, "var.btrfs")); + } + + #[test] + fn update_refetches_a_missing_seed_even_with_a_matching_sha() { + // The seed vanished from the install dir (a crash, a manual + // delete) but the sha is unchanged. Without a presence check the + // plan is empty, `seed_var_disk` no-ops without a source and the + // VM boots with no /var — and since the sha never changes on its + // own, no later update would repair it either. + let dir = tempfile::tempdir().unwrap(); + let installed = manifest("aa", "bb"); + let plan = plan_downloads(&manifest("aa", "bb"), Some(&installed), dir.path()); + assert!(planned(&plan, "var.btrfs")); + assert!(!planned(&plan, "rootfs.erofs-lz4")); + } + + #[test] + fn update_with_nothing_changed_plans_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("var.btrfs"), b"seed").unwrap(); + let installed = manifest("aa", "bb"); + let plan = plan_downloads(&manifest("aa", "bb"), Some(&installed), dir.path()); + assert!(plan.is_empty()); + } + + // ── should_reseed_var — the one decision that destroys user state ── + + #[test] + fn reseed_requires_a_var_sha_change() { + let old = manifest("aa", "bb"); + assert!(should_reseed_var(Some(&old), &manifest("aa2", "bb2"), true)); + // Boot-artifact-only release: var untouched. + assert!(!should_reseed_var(Some(&old), &manifest("aa2", "bb"), true)); + } + + #[test] + fn reseed_never_fires_on_first_install_or_without_a_live_disk() { + let old = manifest("aa", "bb"); + // No installed manifest → no user state to destroy. + assert!(!should_reseed_var(None, &manifest("aa", "bb"), true)); + // VM never started → nothing to reset; claiming otherwise makes + // host applications drop cached install state for no reason. + assert!(!should_reseed_var( + Some(&old), + &manifest("aa2", "bb2"), + false + )); + } + + #[test] + fn repairing_a_missing_seed_does_not_reseed_the_live_disk() { + // `plan_downloads` re-fetches a merely-missing seed at the same + // sha; that repair must never be read as "the release changed + // var" and cost the user their live disk. + let old = manifest("aa", "bb"); + assert!(!should_reseed_var(Some(&old), &manifest("aa", "bb"), true)); + } + + // ── record_var_size_floor — capacity survives the re-seed ── + + use crate::utils::vm::config::VmConfig; + + const GIB: u64 = 1 << 30; + + fn var_size_of(cfg: &VmConfig) -> Option<&str> { + cfg.runtime.as_ref().and_then(|r| r.var_size.as_deref()) + } + + #[test] + fn a_grown_disk_records_its_size() { + let mut cfg = VmConfig::default(); + assert!(record_var_size_floor(&mut cfg, 200 * GIB).unwrap()); + assert_eq!(var_size_of(&cfg), Some("200G")); + // Partial GiB rounds up — recording less than the live disk + // would shrink it on re-seed. + let mut cfg = VmConfig::default(); + assert!(record_var_size_floor(&mut cfg, 200 * GIB + 1).unwrap()); + assert_eq!(var_size_of(&cfg), Some("201G")); + } + + #[test] + fn a_default_sized_disk_records_nothing() { + // 50G file with no config: nothing to preserve, and the config + // file must not grow a runtime section as a side effect. + let mut cfg = VmConfig::default(); + assert!(!record_var_size_floor(&mut cfg, 50 * GIB).unwrap()); + assert!(cfg.runtime.is_none()); + } + + #[test] + fn an_explicit_config_already_covering_the_disk_is_untouched() { + let mut cfg = VmConfig::default(); + cfg.runtime.get_or_insert_with(Default::default).var_size = Some("300G".into()); + assert!(!record_var_size_floor(&mut cfg, 200 * GIB).unwrap()); + assert_eq!(var_size_of(&cfg), Some("300G")); + } + + #[test] + fn a_disk_grown_past_its_config_raises_the_config() { + // Growing (e.g. the desktop's disk-grow) never wrote config, so + // the file can legitimately exceed it; the file is the truth. + let mut cfg = VmConfig::default(); + cfg.runtime.get_or_insert_with(Default::default).var_size = Some("100G".into()); + assert!(record_var_size_floor(&mut cfg, 200 * GIB).unwrap()); + assert_eq!(var_size_of(&cfg), Some("200G")); + } +} diff --git a/src/main.rs b/src/main.rs index 1db8c244..60037251 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3291,12 +3291,14 @@ async fn main() -> Result<()> { channel, check, yes, + reset_var, output, } => { commands::vm::update::UpdateCommand { channel, check_only: check, assume_yes: yes, + reset_var, output, } .execute() @@ -4810,8 +4812,9 @@ enum VmCommands { known_hosts: std::path::PathBuf, }, /// Check for and apply VM image updates from the release channel. - /// Stops + restarts the VM if it was running. Preserves the existing - /// `var` partition; use `vm reset` to wipe state. + /// Stops + restarts the VM if it was running. A release that ships + /// a new `var` image resets the VM's internal state (see + /// --reset-var); otherwise `var` is preserved. Update { /// Channel name (default: `~/.avocado/config.yaml [vm].channel`, /// or `stable` if unset). @@ -4820,9 +4823,16 @@ enum VmCommands { /// Print availability + exit without downloading. #[arg(long)] check: bool, - /// Skip the interactive confirmation prompt. + /// Skip the interactive confirmation prompt. When the release + /// ships a new /var image, --reset-var is also required. #[arg(short = 'y', long)] yes: bool, + /// With --yes: authorize resetting the VM's internal state + /// (installed SDKs, Docker volumes, /data) when the release + /// ships a new /var image. Interactive runs confirm at the + /// prompt instead. + #[arg(long)] + reset_var: bool, /// Output format (human prose or single JSON object). #[arg(long, value_enum, default_value_t = crate::utils::output_format::OutputFormat::Human)] output: crate::utils::output_format::OutputFormat, diff --git a/src/utils/vm/config.rs b/src/utils/vm/config.rs index 55d14631..f5d4f88f 100644 --- a/src/utils/vm/config.rs +++ b/src/utils/vm/config.rs @@ -47,6 +47,13 @@ pub struct RuntimeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub memory_mib: Option, + /// Minimum var disk size, e.g. `"200G"`. `vm start` grows the live + /// disk to at least this (never shrinks), and a var reset re-seeds + /// at it. `vm update` records the live disk's size here before a + /// reset, so a grown disk keeps its capacity across the re-seed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub var_size: Option, + #[serde(flatten)] pub extra: BTreeMap, } @@ -326,6 +333,7 @@ mod tests { runtime: Some(RuntimeConfig { cpus: Some(6), memory_mib: Some(8192), + var_size: Some("200G".into()), extra: Default::default(), }), ..Default::default() diff --git a/src/utils/vm/lifecycle.rs b/src/utils/vm/lifecycle.rs index 8f0b8503..5086de0e 100644 --- a/src/utils/vm/lifecycle.rs +++ b/src/utils/vm/lifecycle.rs @@ -57,7 +57,8 @@ pub struct StartOptions { /// Target size of the persistent var.btrfs (e.g. "50G"). The file is /// truncated up to this size on every start (sparse — no disk use /// until written), then btrfs is resized to fill it inside the VM. - /// Shrinking is refused. `None` → default ([`DEFAULT_VAR_SIZE`]). + /// Shrinking is refused. `None` → the persisted `runtime.var_size` + /// in `~/.avocado/vm/config.yaml`, then [`DEFAULT_VAR_SIZE`]. pub var_size: Option, /// One-shot DNS override for this start only. Wins over the persisted /// `network.dns` in `~/.avocado/vm/config.yaml`; the persisted value @@ -153,11 +154,20 @@ pub async fn start(opts: StartOptions) -> Result { // attaches. `avocado vm rebuild --reset-data` wipes it back to the seed. seed_var_disk(&paths, &manifest, &artifact_dir)?; - // Grow the var.btrfs file (sparse) to the user-requested size, if - // larger than current. The matching `btrfs filesystem resize max` runs - // inside the VM after boot. - let var_target_bytes = parse_size(opts.var_size.as_deref().unwrap_or(DEFAULT_VAR_SIZE)) - .context("invalid --var-size")?; + // Grow the var.btrfs file (sparse) to the requested size, if larger + // than current. Flag > persisted `runtime.var_size` > default. The + // matching `btrfs filesystem resize max` runs inside the VM after + // boot. + let persisted_var_size = super::config::VmConfig::load(&paths) + .ok() + .and_then(|c| c.runtime.and_then(|r| r.var_size)); + let var_size_request = opts + .var_size + .as_deref() + .or(persisted_var_size.as_deref()) + .unwrap_or(DEFAULT_VAR_SIZE); + let var_target_bytes = parse_size(var_size_request) + .with_context(|| format!("invalid var size {var_size_request:?}"))?; grow_var_file(&paths, var_target_bytes)?; let workspace = super::share::resolve_workspace(opts.workspace.as_deref())?; @@ -602,7 +612,7 @@ fn shell_quote(s: &str) -> String { /// Parse a human-readable size string ("50G", "10M", "1024K", "12345" bytes). /// Suffixes are powers of 1024 (KiB/MiB/GiB), matching what `truncate(1)` /// and `btrfs filesystem resize` accept on Linux. -fn parse_size(s: &str) -> Result { +pub(crate) fn parse_size(s: &str) -> Result { let s = s.trim(); if s.is_empty() { bail!("size must not be empty"); diff --git a/src/utils/vm_update_check.rs b/src/utils/vm_update_check.rs index 89219d8e..3c595d5d 100644 --- a/src/utils/vm_update_check.rs +++ b/src/utils/vm_update_check.rs @@ -48,26 +48,49 @@ pub struct UpdateAvailable { pub installed_version: Option, } -/// Returns the channel pointer if a newer VM is available for the -/// given channel, otherwise `None`. Reads `installed_version` from the -/// caller (typically `~/.avocado/vm/manifest.json`'s `.version` field). +/// Outcome of a channel check. +pub enum VmUpdateStatus { + /// A newer release exists and this CLI is allowed to install it. + Available(UpdateAvailable), + /// A newer release exists but the channel's `min_cli_version` is + /// above ours. Carries the actionable message from + /// [`ChannelPointer::check_cli_compatibility`]. + /// + /// This has to be a distinct variant rather than folding into + /// `NoUpdate`: `min_cli_version` is the mechanism that stops an old + /// CLI from *half*-applying a release (see the var-reseed handling + /// in `vm update`), so a refusal the user can't see is a CLI that + /// silently never updates its VM again. + CliTooOld { message: String, remote: String }, + /// Nothing newer — or the check couldn't run at all (network, + /// filesystem, unparseable pointer, `AVOCADO_NO_UPDATE_CHECK`). + /// Deliberately conflated: a check we couldn't perform must never + /// read as "an update is waiting." + NoUpdate, +} + +/// Ask the channel whether a newer VM is available. Reads +/// `installed_version` from the caller (typically +/// `~/.avocado/vm/manifest.json`'s `.version` field). /// /// Results are cached for 24 hours in /// `/vm_update_check.json`. Set /// `AVOCADO_NO_UPDATE_CHECK` to skip the check entirely. /// -/// Fails silently on network or filesystem errors — returns `None`. -pub async fn check_for_vm_update( - channel: &str, - installed_version: Option<&str>, -) -> Option { +/// Network and filesystem errors degrade to +/// [`VmUpdateStatus::NoUpdate`] — a background poll must not fail a +/// command. A `min_cli_version` refusal is *not* an error of that kind +/// and is reported as [`VmUpdateStatus::CliTooOld`]. +pub async fn check_for_vm_update(channel: &str, installed_version: Option<&str>) -> VmUpdateStatus { if std::env::var("AVOCADO_NO_UPDATE_CHECK").is_ok() { - return None; + return VmUpdateStatus::NoUpdate; } - poll(channel, installed_version).await.ok().flatten() + poll(channel, installed_version) + .await + .unwrap_or(VmUpdateStatus::NoUpdate) } -async fn poll(channel: &str, installed_version: Option<&str>) -> Result> { +async fn poll(channel: &str, installed_version: Option<&str>) -> Result { let cache_path = cache_path(); let now_secs = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); @@ -100,15 +123,23 @@ async fn poll(channel: &str, installed_version: Option<&str>) -> Result) -> Option { - let pointer = ChannelPointer::parse(raw, channel).ok()?; - pointer - .check_cli_compatibility(env!("CARGO_PKG_VERSION")) - .ok()?; +fn decide(raw: &str, channel: &str, installed_version: Option<&str>) -> VmUpdateStatus { + let Ok(pointer) = ChannelPointer::parse(raw, channel) else { + return VmUpdateStatus::NoUpdate; + }; + // Newness first, compatibility second. A channel whose + // `min_cli_version` is above ours but whose release we already have + // installed is not something to report — the user has nothing to do. if !pointer.is_newer_than(installed_version) { - return None; + return VmUpdateStatus::NoUpdate; + } + if let Err(err) = pointer.check_cli_compatibility(env!("CARGO_PKG_VERSION")) { + return VmUpdateStatus::CliTooOld { + message: err.to_string(), + remote: pointer.version, + }; } - Some(UpdateAvailable { + VmUpdateStatus::Available(UpdateAvailable { pointer, installed_version: installed_version.map(str::to_string), }) @@ -129,3 +160,82 @@ fn cache_path() -> Option { let dirs = ProjectDirs::from("", "", "avocado")?; Some(dirs.cache_dir().join("vm_update_check.json")) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Version far above anything this CLI will ever report, so the + /// test doesn't need updating when the crate version moves. + const UNREACHABLE_CLI: &str = "999.0.0"; + + fn pointer_json(version: &str, min_cli: &str) -> String { + format!( + r#"{{ + "channel": "stable", + "version": "{version}", + "released_at": "2026-08-14T00:00:00Z", + "platforms": {{ + "avocado-qemuarm64": {{ + "manifest_url": "https://example.invalid/{version}/arm64/manifest.json", + "base_url": "https://example.invalid/{version}/arm64/" + }} + }}, + "min_cli_version": "{min_cli}" + }}"# + ) + } + + #[test] + fn a_newer_release_this_cli_cannot_install_is_reported_not_swallowed() { + // `min_cli_version` is what stops an old CLI half-applying a + // release. Collapsing the refusal into NoUpdate — as this used + // to — leaves the user told they're current, forever, with no + // hint that upgrading the CLI is what unblocks them. + let status = decide( + &pointer_json("0.4.0", UNREACHABLE_CLI), + "stable", + Some("0.3.0"), + ); + let VmUpdateStatus::CliTooOld { message, remote } = status else { + panic!("expected CliTooOld"); + }; + assert!(message.contains("0.4.0"), "names the release: {message}"); + assert!( + message.contains("avocado upgrade"), + "tells the user what to do: {message}", + ); + // Carried separately so `--check --output json` can report the + // blocked release as a field, not just inside prose. + assert_eq!(remote, "0.4.0"); + } + + #[test] + fn an_incompatible_release_we_already_have_is_not_worth_reporting() { + // Newness is checked before compatibility on purpose: there is + // nothing for the user to act on when the release they'd be + // refused is the one already installed. + let status = decide( + &pointer_json("0.4.0", UNREACHABLE_CLI), + "stable", + Some("0.4.0"), + ); + assert!(matches!(status, VmUpdateStatus::NoUpdate)); + } + + #[test] + fn a_newer_compatible_release_is_available() { + let status = decide(&pointer_json("0.4.0", "0.1.0"), "stable", Some("0.3.0")); + let VmUpdateStatus::Available(avail) = status else { + panic!("expected Available"); + }; + assert_eq!(avail.pointer.version, "0.4.0"); + assert_eq!(avail.installed_version.as_deref(), Some("0.3.0")); + } + + #[test] + fn an_unparseable_pointer_never_reads_as_an_available_update() { + let status = decide("{ not json", "stable", Some("0.3.0")); + assert!(matches!(status, VmUpdateStatus::NoUpdate)); + } +}