From 6b1dbebe5ac7945110ad55859ec9db680c2d574a Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 21 Jun 2026 14:12:43 +0200 Subject: [PATCH] =?UTF-8?q?hive-c0re:=20hivectl=20`subvol=20upgrade`=20?= =?UTF-8?q?=E2=80=94=20migrate=20an=20agent=20state=20dir=20to=20a=20btrfs?= =?UTF-8?q?=20subvolume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New agents get a btrfs subvolume state root automatically when the host FS is btrfs, but agents that predate that migration are left on plain dirs and miss the subvolume feature set (snapshots, per-subvol usage/quota, send/receive migration). Add an opt-in operator verb to convert an existing plain-dir agent in place. btrfs cannot promote a directory to a subvolume in place, so the new privileged op stages a sibling subvolume mirroring the dir (create + `cp -a --reflink=auto` preserving ownership/permissions/xattrs + match the root's owner and mode), then atomically renames the original aside and the subvolume into place, then removes the original. Any failure before the swap leaves the original untouched; idempotent (no-op if already a subvolume) and btrfs-gated. The `hivectl subvol upgrade --yes` verb composes it client-side like `restart`: stop the agent so its state bind-mount is released, run the migration via hive-priv, then restart it — the restart is attempted regardless of the migration outcome so a failed migration never leaves the agent down. - hive-sh4re: UpgradeAgentSubvolume priv request variant. - hive-priv: the migration handler plus stage/cleanup helpers. - hive-c0re: priv_client wrapper and the hivectl verb; regen CLI docs. --- docs/tools/hivectl-cli.md | 33 +++++++ hive-c0re/src/bin/hivectl.rs | 108 ++++++++++++++++++++++ hive-c0re/src/priv_client.rs | 16 ++++ hive-priv/src/main.rs | 171 +++++++++++++++++++++++++++++++++++ hive-sh4re/src/priv_proto.rs | 23 +++++ 5 files changed, 351 insertions(+) diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index c6a49a48..4b466440 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -32,6 +32,8 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl quota enable`↴](#hivectl-quota-enable) * [`hivectl quota show`↴](#hivectl-quota-show) * [`hivectl quota limit`↴](#hivectl-quota-limit) +* [`hivectl subvol`↴](#hivectl-subvol) +* [`hivectl subvol upgrade`↴](#hivectl-subvol-upgrade) * [`hivectl completions`↴](#hivectl-completions) ## `hivectl` @@ -52,6 +54,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `start` — Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon * `restart` — Restart containers hive-wide — `stop` then `start` over the same scope. Bare `hivectl restart` restarts **everything** (all sub-agents plus the ci/forge/gateway/matrix infra containers); the same scope flags as `stop`/`start` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). If the stop phase reports a failure the start phase is skipped so the operator can investigate. Requires the hive-c0re daemon * `quota` — Per-agent disk accounting + optional quotas via btrfs qgroups +* `subvol` — btrfs subvolume management for agent state dirs * `completions` — Generate a shell completion script for `hivectl` and print it to stdout ###### **Options:** @@ -465,6 +468,36 @@ Set or clear an agent's disk quota (a referenced-usage cap). `size` accepts a by +## `hivectl subvol` + +btrfs subvolume management for agent state dirs. + +New agents get a btrfs subvolume state root automatically (when the host FS is btrfs); agents that predate that are left on plain dirs. `subvol upgrade ` opts an existing plain-dir agent into the subvolume feature set (snapshots, per-subvol usage/quota, migration) by migrating its state dir in place. Requires the hive-c0re daemon (for the stop/start) and root (for the privileged migration). + +**Usage:** `hivectl subvol ` + +###### **Subcommands:** + +* `upgrade` — Convert an existing plain-dir agent state root into a btrfs subvolume in place. Stops the agent (so its state bind-mount is released), migrates `…/agents//` to a subvolume preserving ownership/permissions/xattrs, then restarts it. Idempotent (no-op if already a subvolume) and safe (the original dir is left untouched on any failure before the final swap). Requires `--yes` since it bounces the agent and moves its state + + + +## `hivectl subvol upgrade` + +Convert an existing plain-dir agent state root into a btrfs subvolume in place. Stops the agent (so its state bind-mount is released), migrates `…/agents//` to a subvolume preserving ownership/permissions/xattrs, then restarts it. Idempotent (no-op if already a subvolume) and safe (the original dir is left untouched on any failure before the final swap). Requires `--yes` since it bounces the agent and moves its state + +**Usage:** `hivectl subvol upgrade [OPTIONS] ` + +###### **Arguments:** + +* `` — Agent name (e.g. `damocles`, `iris`) + +###### **Options:** + +* `--yes` — Confirm: this stops the agent, migrates its state dir, and restarts it. Required — the command refuses without it + + + ## `hivectl completions` Generate a shell completion script for `hivectl` and print it to stdout. diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index bc24d2de..2f278c60 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -173,6 +173,18 @@ enum Cmd { #[command(subcommand)] cmd: QuotaCmd, }, + /// btrfs subvolume management for agent state dirs. + /// + /// New agents get a btrfs subvolume state root automatically (when the + /// host FS is btrfs); agents that predate that are left on plain dirs. + /// `subvol upgrade ` opts an existing plain-dir agent into the + /// subvolume feature set (snapshots, per-subvol usage/quota, migration) + /// by migrating its state dir in place. Requires the hive-c0re daemon + /// (for the stop/start) and root (for the privileged migration). + Subvol { + #[command(subcommand)] + cmd: SubvolCmd, + }, /// Emit the full CLI reference as `CommonMark` to stdout. /// /// Hidden tooling command (not part of day-to-day operator admin): @@ -487,6 +499,25 @@ enum AgentsCmd { RestartAll, } +#[derive(Subcommand)] +enum SubvolCmd { + /// Convert an existing plain-dir agent state root into a btrfs subvolume + /// in place. Stops the agent (so its state bind-mount is released), + /// migrates `…/agents//` to a subvolume preserving + /// ownership/permissions/xattrs, then restarts it. Idempotent (no-op if + /// already a subvolume) and safe (the original dir is left untouched on + /// any failure before the final swap). Requires `--yes` since it bounces + /// the agent and moves its state. + Upgrade { + /// Agent name (e.g. `damocles`, `iris`). + name: String, + /// Confirm: this stops the agent, migrates its state dir, and + /// restarts it. Required — the command refuses without it. + #[arg(long)] + yes: bool, + }, +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -546,6 +577,9 @@ async fn main() -> Result<()> { Cmd::Stop { scope, graceful } => stop(&socket, scope.to_scope(), graceful).await, Cmd::Start { scope } => start(&socket, scope.to_scope()).await, Cmd::Restart { scope, graceful } => restart(&socket, scope.to_scope(), graceful).await, + Cmd::Subvol { cmd } => match cmd { + SubvolCmd::Upgrade { name, yes } => subvol_upgrade(&socket, &name, yes).await, + }, Cmd::Choom { name, fresh } => choom(&name, fresh), Cmd::Quota { cmd } => match cmd { QuotaCmd::Enable => quota_enable().await, @@ -1255,6 +1289,80 @@ async fn restart(socket: &Path, scope: hive_sh4re::LifecycleScope, graceful: boo start(socket, scope).await } +/// A [`LifecycleScope`](hive_sh4re::LifecycleScope) targeting exactly one +/// agent by name (no infra containers, no all-agents flag). +fn single_agent_scope(name: &str) -> hive_sh4re::LifecycleScope { + hive_sh4re::LifecycleScope { + agents: false, + agent_names: vec![name.to_owned()], + ci: false, + forge: false, + gateway: false, + matrix: false, + } +} + +/// `subvol upgrade ` — migrate an existing plain-dir agent state root +/// to a btrfs subvolume. Composed client-side (like `restart`): stop the +/// agent so its state bind-mount is released, run the privileged in-place +/// migration via hive-priv, then restart it. The restart is attempted +/// regardless of the migration outcome so a failed migration never leaves +/// the agent down; the migration error (if any) is surfaced afterwards. +async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { + if !is_agent(name) { + bail!("no agent named {name:?} (no state dir under the agents root)"); + } + if !yes { + bail!( + "`subvol upgrade {name}` stops the agent, migrates its state dir to a btrfs \ + subvolume, then restarts it. Re-run with --yes to proceed." + ); + } + + println!("stopping {name} (releasing its state bind-mount)…"); + let stop_resp = hive_c0re::client::request( + socket, + hive_sh4re::HostRequest::Stop { + scope: single_agent_scope(name), + graceful: false, + }, + ) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + if !stop_resp.ok { + bail!( + "stop {name}: {}", + stop_resp.error.as_deref().unwrap_or("unknown error") + ); + } + + println!("migrating {name} state dir to a btrfs subvolume…"); + let upgrade = hive_c0re::priv_client::upgrade_agent_subvolume(name).await; + + // Always restart, even if the migration failed — don't leave the agent down. + println!("starting {name}…"); + let start_resp = hive_c0re::client::request( + socket, + hive_sh4re::HostRequest::Start { + scope: single_agent_scope(name), + }, + ) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + + // Surface the migration error first — it's the meaningful one — but only + // after the restart attempt above. + upgrade.with_context(|| format!("upgrade {name} state subvolume"))?; + if !start_resp.ok { + bail!( + "migration succeeded but restarting {name} failed: {}", + start_resp.error.as_deref().unwrap_or("unknown error") + ); + } + println!("upgraded {name} to a btrfs subvolume and restarted it"); + Ok(()) +} + /// Render a hive-wide stop/start response: one `: ` line per /// touched container, then surface any aggregated per-target failure as a /// non-zero exit. `verb` is the past-tense word printed per item diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 25582347..55c1cf58 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -368,6 +368,22 @@ pub async fn set_subvolume_quota(agent_name: &str, limit_bytes: Option) -> .await?) } +/// Convert an existing plain-dir agent state root into a btrfs subvolume in +/// place (operator opt-in; via hive-priv as root). The caller must stop the +/// agent first (so its state bind-mount is gone) and restart it after. +/// Idempotent: a no-op when the root is already a subvolume. +/// +/// # Errors +/// Returns an error if the hive-priv call fails, the state dir is missing, +/// the FS isn't btrfs, or the migration (subvolume create / copy / swap) +/// fails — in which case the original dir is left untouched. +pub async fn upgrade_agent_subvolume(agent_name: &str) -> Result<()> { + ok(call(&PrivRequest::UpgradeAgentSubvolume { + agent_name: agent_name.to_owned(), + }) + .await?) +} + /// Parse `(referenced, exclusive)` bytes from `btrfs qgroup show -f --raw` /// output (a qgroup row is ` …`). /// diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 4d5fffd0..012af4d5 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -297,6 +297,11 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, validate_agent_name(agent_name)?; set_subvolume_quota(agent_name, limit_bytes).await } + + PrivRequest::UpgradeAgentSubvolume { ref agent_name } => { + validate_agent_name(agent_name)?; + upgrade_agent_subvolume(agent_name).await + } } } @@ -700,6 +705,172 @@ async fn read_subvolume_usage(agent_name: &str) -> Result<(String, String)> { )) } +/// Best-effort removal of a leftover migration path from a prior aborted +/// upgrade: try `btrfs subvolume delete` (in case it's a half-created +/// subvolume) then a plain recursive remove. Both failures are ignored — +/// the path may simply not exist. +async fn cleanup_stale_path(path: &Path) { + if path.exists() { + let _ = Command::new("btrfs") + .args(["subvolume", "delete"]) + .arg(path) + .output() + .await; + let _ = std::fs::remove_dir_all(path); + } +} + +/// Stage a populated subvolume at `tmp` mirroring `agent_root`: create the +/// subvolume, copy `agent_root`'s contents into it preserving +/// ownership/permissions/xattrs, then match the subvolume root's owner + mode +/// to the original. On any failure the partially-staged `tmp` is cleaned up +/// (so the caller can bail with the original dir still untouched). +async fn stage_upgrade_subvolume(agent_root: &Path, tmp: &Path) -> Result<()> { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + // Fresh subvolume to receive the copy. + let out = Command::new("btrfs") + .args(["subvolume", "create"]) + .arg(tmp) + .output() + .await + .with_context(|| format!("spawn btrfs subvolume create {}", tmp.display()))?; + if !out.status.success() { + bail!( + "btrfs subvolume create {} failed: {}", + tmp.display(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + + // Copy contents preserving everything (`-a` = --preserve=all → mode, + // ownership, timestamps, links, xattrs); reflink for fast CoW clones on the + // same btrfs. `/.` copies the directory's contents (incl. dotfiles) + // into the subvolume rather than nesting it. + let copy = Command::new("cp") + .arg("-a") + .arg("--reflink=auto") + .arg(format!("{}/.", agent_root.display())) + .arg(tmp) + .output() + .await + .with_context(|| format!("spawn cp into {}", tmp.display()))?; + if !copy.status.success() { + cleanup_stale_path(tmp).await; + bail!( + "copy {} -> {} failed (original left untouched): {}", + agent_root.display(), + tmp.display(), + String::from_utf8_lossy(©.stderr).trim() + ); + } + + // Match the new subvolume root's ownership + mode to the original dir. + // `cp -a /.` copies the *contents* but the subvolume root keeps its + // create-time root ownership, so set it explicitly — the swapped-in + // subvolume must be indistinguishable from the original to hive-c0re. + let apply = std::fs::metadata(agent_root) + .with_context(|| format!("stat {} for ownership", agent_root.display())) + .and_then(|m| { + std::os::unix::fs::chown(tmp, Some(m.uid()), Some(m.gid())) + .with_context(|| format!("chown {} to match original", tmp.display()))?; + std::fs::set_permissions(tmp, std::fs::Permissions::from_mode(m.mode())) + .with_context(|| format!("chmod {} to match original", tmp.display()))?; + Ok(()) + }); + if let Err(e) = apply { + cleanup_stale_path(tmp).await; + return Err(e.context("upgrade aborted before swap; original left untouched")); + } + Ok(()) +} + +/// `UpgradeAgentSubvolume` — convert an existing plain-dir agent state root +/// into a btrfs subvolume in place. Operator opt-in; the caller (hivectl) +/// stops the agent first and restarts it after. See the wire doc. +/// +/// Migration: stage a sibling subvolume mirroring the dir +/// ([`stage_upgrade_subvolume`]), then rename the original aside and the +/// subvolume into place, then remove the original. Any failure before the +/// rename-swap leaves the original dir untouched. +async fn upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { + let root = PathBuf::from(AGENT_STATE_ROOT); + let agent_root = root.join(agent_name); + + if !agent_root.exists() { + bail!( + "no state dir to upgrade at {} — nothing to do", + agent_root.display() + ); + } + // Idempotent: already a subvolume → nothing to do. + if is_btrfs_subvolume(&agent_root) { + return Ok((String::new(), String::new())); + } + if !is_on_btrfs(&root)? { + bail!( + "{} is not on btrfs — subvolumes are unsupported, cannot upgrade", + root.display() + ); + } + + // Sibling temp paths on the same filesystem (so the copy can reflink and + // the swap renames are atomic). Leading dots keep them out of the agent + // namespace (`validate_agent_name` rejects dot-prefixed names). + let tmp = root.join(format!(".{agent_name}.migrating")); + let old = root.join(format!(".{agent_name}.old")); + // Clear any debris from a previously interrupted run before starting. + cleanup_stale_path(&tmp).await; + cleanup_stale_path(&old).await; + + stage_upgrade_subvolume(&agent_root, &tmp).await?; + + // Swap. `rename` is atomic within a filesystem. The window between the + // two renames is the only unsafe point: a crash there leaves the agent + // root missing but both `.old` (original) and the new subvolume present + // — recoverable by hand, hence the loud logging. + if let Err(e) = std::fs::rename(&agent_root, &old) { + cleanup_stale_path(&tmp).await; + return Err(anyhow::Error::new(e).context(format!( + "rename {} -> {} failed; original left untouched", + agent_root.display(), + old.display() + ))); + } + if let Err(e) = std::fs::rename(&tmp, &agent_root) { + // Restore the original from its renamed-aside copy. + let restored = std::fs::rename(&old, &agent_root).is_ok(); + cleanup_stale_path(&tmp).await; + return Err(anyhow::Error::new(e).context(format!( + "rename {} -> {} failed; original {}", + tmp.display(), + agent_root.display(), + if restored { + "restored" + } else { + "COULD NOT BE RESTORED — manual recovery needed" + } + ))); + } + + // 5. Success: drop the original (a plain dir) and report. + if let Err(e) = std::fs::remove_dir_all(&old) { + // The migration succeeded; a leftover `.old` is cosmetic. Warn only. + tracing::warn!( + agent = %agent_name, path = %old.display(), + "upgraded subvolume but failed to remove old dir: {e}" + ); + } + tracing::info!( + agent = %agent_name, path = %agent_root.display(), + "upgraded agent state dir to btrfs subvolume" + ); + Ok(( + format!("upgraded {} to a btrfs subvolume", agent_root.display()), + String::new(), + )) +} + /// `SetSubvolumeQuota` — set or clear a qgroup size limit on an agent /// subvolume (`btrfs qgroup limit <…/agent_name>`). See the /// wire doc. diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 3e8b08ae..48ef4d1d 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -406,6 +406,29 @@ pub enum PrivRequest { /// Byte cap on referenced usage; `None` clears the limit. limit_bytes: Option, }, + + /// Convert an existing **plain-directory** agent state root into a btrfs + /// subvolume in place. The operator opt-in counterpart to the progressive + /// `EnsureAgentSubvolume` (which only ever makes *new* agents subvolumes): + /// it migrates an already-existing plain dir so the agent gains the + /// subvolume feature set (snapshots, per-subvol usage/quota, send/receive). + /// + /// btrfs cannot promote a directory in place, so the helper does the move: + /// create a fresh subvolume, copy the dir's contents into it preserving + /// ownership/permissions/xattrs (`cp -a --reflink=auto`), then atomically + /// rename the original aside and the subvolume into place, and finally + /// remove the original. The caller (hivectl) MUST stop the agent first so + /// its state bind-mount is gone before the host dir moves, and restart it + /// after. Behaviour: + /// - path missing → error (nothing to upgrade); + /// - already a subvolume → no-op success (idempotent); + /// - parent FS not btrfs → error (subvolumes unsupported here); + /// - any failure before the final swap leaves the original dir untouched + /// (no half-migration). Requires root. + UpgradeAgentSubvolume { + /// Logical agent name (validated by `validate_agent_name`). + agent_name: String, + }, } /// Response from the privileged helper.