hive-c0re: hivectl subvol upgrade — migrate an agent state dir to a btrfs subvolume

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 <agent> --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.
This commit is contained in:
atlas 2026-06-21 14:12:43 +02:00 committed by mara
commit 6b1dbebe5a
5 changed files with 351 additions and 0 deletions

View file

@ -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 <name>`). 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 <name>`). 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 <agent>` 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 <COMMAND>`
###### **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/<name>/` 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/<name>/` 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] <NAME>`
###### **Arguments:**
* `<NAME>` — 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.

View file

@ -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 <agent>` 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/<name>/` 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 <agent>` — 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 `<verb>: <name>` 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

View file

@ -368,6 +368,22 @@ pub async fn set_subvolume_quota(agent_name: &str, limit_bytes: Option<u64>) ->
.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 `<id-with-slash> <rfer> <excl> …`).
///

View file

@ -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. `<src>/.` 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(&copy.stderr).trim()
);
}
// Match the new subvolume root's ownership + mode to the original dir.
// `cp -a <src>/.` 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 <bytes|none> <…/agent_name>`). See the
/// wire doc.

View file

@ -406,6 +406,29 @@ pub enum PrivRequest {
/// Byte cap on referenced usage; `None` clears the limit.
limit_bytes: Option<u64>,
},
/// 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.