refactor(#2352): subvolume snapshot ops via daemon wire commands

This commit is contained in:
damocles 2026-07-15 00:33:57 +02:00 committed by mara
commit 7b54e7aa50
3 changed files with 138 additions and 29 deletions

View file

@ -740,19 +740,7 @@ async fn main() -> Result<()> {
} => stop(&socket, scope.to_scope(), graceful, no_wait).await,
Cmd::Start { scope, no_wait } => start(&socket, scope.to_scope(), no_wait).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,
SubvolCmd::Snapshot { cmd } => match cmd {
SnapshotCmd::Create { name, label } => subvol_snapshot_create(&name, label).await,
SnapshotCmd::Delete { name, label } => subvol_snapshot_delete(&name, &label).await,
SnapshotCmd::Send {
name,
label,
parent,
dest,
} => subvol_snapshot_send(&name, &label, parent.as_deref(), &dest).await,
},
},
Cmd::Subvol { cmd } => dispatch_subvol(&socket, cmd).await,
Cmd::Choom {
name,
resume_session,
@ -1714,6 +1702,29 @@ fn single_agent_scope(name: &str) -> hive_host_sock::LifecycleScope {
/// 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.
/// Route a `hivectl subvol …` subcommand. Split out of `main`'s top-level
/// match so the CLI router stays within the clippy line budget and the
/// subvolume-op subcommands are dispatched in one place.
async fn dispatch_subvol(socket: &Path, cmd: SubvolCmd) -> Result<()> {
match cmd {
SubvolCmd::Upgrade { name, yes } => subvol_upgrade(socket, &name, yes).await,
SubvolCmd::Snapshot { cmd } => match cmd {
SnapshotCmd::Create { name, label } => {
subvol_snapshot_create(socket, &name, label).await
}
SnapshotCmd::Delete { name, label } => {
subvol_snapshot_delete(socket, &name, &label).await
}
SnapshotCmd::Send {
name,
label,
parent,
dest,
} => subvol_snapshot_send(socket, &name, &label, parent.as_deref(), &dest).await,
},
}
}
async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
if !agent_exists(name)? {
bail!("no agent named {name:?} (no state dir under the agents root)");
@ -1749,7 +1760,14 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
.with_context(|| format!("waiting for {name} to stop before the migration"))?;
println!("migrating {name} state dir to a btrfs subvolume…");
let upgrade = hive_c0re::priv_client::upgrade_agent_subvolume(name).await;
let upgrade = daemon_request(
socket,
hive_host_sock::HostRequest::UpgradeSubvolume {
name: name.to_owned(),
},
"upgrade",
)
.await;
// Always attempt the restart, even if the migration failed — don't leave
// the agent down. Capture the result rather than `?`-ing it so a
@ -1806,7 +1824,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
/// — mara: "we are making up the rules here, lets go strict"). hive-priv
/// enforces the same rules server-side, so this check is
/// belt-and-suspenders (fail fast client-side with a clear message).
async fn subvol_snapshot_create(name: &str, label: String) -> Result<()> {
async fn subvol_snapshot_create(socket: &Path, name: &str, label: String) -> Result<()> {
if !agent_exists(name)? {
bail!("no agent named {name:?} (no state dir under the agents root)");
}
@ -1821,19 +1839,31 @@ async fn subvol_snapshot_create(name: &str, label: String) -> Result<()> {
"snapshot label {label:?} must be [A-Za-z0-9_-] only (no \".\" — hive-priv rejects it)"
);
}
let path = hive_c0re::priv_client::snapshot_agent_subvolume(name, &label)
.await
.with_context(|| format!("snapshot {name} state subvolume (label {label:?})"))?;
println!("{path}");
Ok(())
// The daemon returns the snapshot's host path as a message line, which
// `daemon_request` prints — same bare-path output as before.
daemon_request(
socket,
hive_host_sock::HostRequest::SnapshotSubvolume {
name: name.to_owned(),
label,
},
"snapshot",
)
.await
}
/// `subvol snapshot delete <agent> <label>` — remove a snapshot created by
/// `subvol snapshot create`.
async fn subvol_snapshot_delete(name: &str, label: &str) -> Result<()> {
hive_c0re::priv_client::delete_agent_snapshot(name, label)
.await
.with_context(|| format!("delete {name} snapshot (label {label:?})"))?;
async fn subvol_snapshot_delete(socket: &Path, name: &str, label: &str) -> Result<()> {
daemon_request(
socket,
hive_host_sock::HostRequest::DeleteSnapshot {
name: name.to_owned(),
label: label.to_owned(),
},
"snapshot delete",
)
.await?;
println!("deleted snapshot {label:?} for {name}");
Ok(())
}
@ -1844,16 +1874,25 @@ async fn subvol_snapshot_delete(name: &str, label: &str) -> Result<()> {
/// charset check, which excludes `/`), written under the migrate-staging
/// dir. Prints the resulting file's host path.
async fn subvol_snapshot_send(
socket: &Path,
name: &str,
label: &str,
parent: Option<&str>,
dest: &str,
) -> Result<()> {
let path = hive_c0re::priv_client::send_agent_snapshot_to_file(name, label, parent, dest)
.await
.with_context(|| format!("send {name} snapshot (label {label:?}) to file {dest:?}"))?;
println!("{path}");
Ok(())
// The daemon returns the written file's host path as a message line,
// which `daemon_request` prints — same bare-path output as before.
daemon_request(
socket,
hive_host_sock::HostRequest::SendSnapshot {
name: name.to_owned(),
label: label.to_owned(),
parent: parent.map(str::to_owned),
dest: dest.to_owned(),
},
"snapshot send",
)
.await
}
/// Render a hive-wide stop/start response: one `<verb>: <name>` line per

View file

@ -202,6 +202,19 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::QuotaEnable => handle_quota_enable().await?,
HostRequest::QuotaLimit { name, limit } => handle_quota_limit(name, *limit).await?,
HostRequest::QuotaShow { name } => handle_quota_show(name.as_deref()).await?,
HostRequest::UpgradeSubvolume { name } => handle_upgrade_subvolume(name).await?,
HostRequest::SnapshotSubvolume { name, label } => {
handle_snapshot_subvolume(name, label).await?
}
HostRequest::DeleteSnapshot { name, label } => {
handle_delete_snapshot(name, label).await?
}
HostRequest::SendSnapshot {
name,
label,
parent,
dest,
} => handle_send_snapshot(name, label, parent.as_deref(), dest).await?,
})
}
.await;
@ -469,6 +482,39 @@ async fn handle_quota_show(name: Option<&str>) -> Result<HostResponse> {
Ok(HostResponse::quota(rows))
}
async fn handle_upgrade_subvolume(name: &str) -> Result<HostResponse> {
crate::priv_client::upgrade_agent_subvolume(name)
.await
.with_context(|| format!("upgrade {name} state subvolume"))?;
Ok(HostResponse::success())
}
async fn handle_snapshot_subvolume(name: &str, label: &str) -> Result<HostResponse> {
let path = crate::priv_client::snapshot_agent_subvolume(name, label)
.await
.with_context(|| format!("snapshot {name} state subvolume (label {label:?})"))?;
Ok(HostResponse::messages(vec![path]))
}
async fn handle_delete_snapshot(name: &str, label: &str) -> Result<HostResponse> {
crate::priv_client::delete_agent_snapshot(name, label)
.await
.with_context(|| format!("delete {name} snapshot (label {label:?})"))?;
Ok(HostResponse::success())
}
async fn handle_send_snapshot(
name: &str,
label: &str,
parent: Option<&str>,
dest: &str,
) -> Result<HostResponse> {
let path = crate::priv_client::send_agent_snapshot_to_file(name, label, parent, dest)
.await
.with_context(|| format!("send {name} snapshot (label {label:?}) to file {dest:?}"))?;
Ok(HostResponse::messages(vec![path]))
}
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
require_matrix_present().await?;
let register_token =

View file

@ -188,6 +188,30 @@ pub enum HostRequest {
#[serde(default)]
name: Option<String>,
},
/// Migrate an agent's plain state dir to a btrfs subvolume via the
/// privileged helper (`hivectl subvol upgrade`). The agent MUST already
/// be stopped — the client orchestrates stop → this → start. Returns a
/// bare success; the client prints its own progress lines.
UpgradeSubvolume { name: String },
/// Create a read-only btrfs snapshot of an agent's state subvolume
/// (`hivectl subvol snapshot create`). `label` is validated client-side
/// AND by hive-priv. Returns the snapshot's host path in
/// [`HostResponse::messages`].
SnapshotSubvolume { name: String, label: String },
/// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol
/// snapshot delete`). Bare success; the client prints the confirmation.
DeleteSnapshot { name: String, label: String },
/// Export a snapshot to a local file via `btrfs send` (`hivectl subvol
/// snapshot send`). `dest` is a bare filename (hive-priv rejects paths);
/// `parent` names an optional parent snapshot for an incremental send.
/// Returns the written file's host path in [`HostResponse::messages`].
SendSnapshot {
name: String,
label: String,
#[serde(default)]
parent: Option<String>,
dest: String,
},
}
/// One agent's btrfs qgroup usage row — the [`HostRequest::QuotaShow`]