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

@ -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