hyperhive/hivectl/src/subvol.rs

222 lines
8.5 KiB
Rust

//! `hivectl subvol` — btrfs state-subvolume ops: migrate a plain-dir agent
//! state root to a subvolume (`upgrade`), and snapshot create/delete/send.
use std::path::Path;
use anyhow::{Context as _, Result, bail};
use crate::cli::{SnapshotCmd, SubvolCmd};
use crate::dag_progress::wait_for_dags;
use crate::util::{agent_exists, daemon_request};
/// A [`LifecycleScope`](hive_host_sock::LifecycleScope) targeting exactly one
/// agent by name (no infra containers, no all-agents flag).
fn single_agent_scope(name: &str) -> hive_host_sock::LifecycleScope {
hive_host_sock::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.
/// 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.
pub(crate) 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)");
}
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 = crate::client::request(
socket,
hive_host_sock::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")
);
}
// The stop is a queued DAG now — the migration below snapshots +
// swaps the state dir and MUST NOT run under a live bind mount, so
// wait for the stop to actually execute before touching anything.
wait_for_dags(socket, stop_resp.queued_dags.unwrap_or_default(), false)
.await
.with_context(|| format!("waiting for {name} to stop before the migration"))?;
println!("migrating {name} state dir to a btrfs subvolume…");
let upgrade = daemon_request(
socket,
hive_host_sock::HostRequest::UpgradeSubvolume {
name: crate::util::parse_ident(name)?,
},
"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
// start-side failure (incl. the IPC call itself erroring) can't mask the
// migration outcome below.
println!("starting {name}");
let start_result = crate::client::request(
socket,
hive_host_sock::HostRequest::Start {
scope: single_agent_scope(name),
},
)
.await;
// Surface the migration outcome FIRST — it's the meaningful result and
// must not be shadowed by a restart-side failure. On migration failure the
// original state dir is untouched (the priv op rolls back before the swap).
upgrade.with_context(|| format!("upgrade {name} state subvolume"))?;
// Migration succeeded; now surface any restart problem — either the IPC
// call erroring, or the daemon reporting a failed start. The migration is
// done regardless, so point at the manual recovery.
let start_resp = start_result.with_context(|| {
format!(
"{name} migrated to a btrfs subvolume, but the restart request to the daemon \
socket {} failed — run `hivectl start --agent {name}` to bring it back up",
socket.display()
)
})?;
if !start_resp.ok {
bail!(
"{name} migrated to a btrfs subvolume, but restarting it failed: {} — run \
`hivectl start --agent {name}` to retry",
start_resp.error.as_deref().unwrap_or("unknown error")
);
}
wait_for_dags(socket, start_resp.queued_dags.unwrap_or_default(), false)
.await
.with_context(|| {
format!(
"{name} migrated to a btrfs subvolume, but its restart job failed — run \
`hivectl start --agent {name}` to retry"
)
})?;
println!("upgraded {name} to a btrfs subvolume and restarted it");
Ok(())
}
/// `subvol snapshot create <agent> --label <label>` — create a read-only
/// btrfs snapshot of an agent's state subvolume. Unlike `upgrade`, this does
/// NOT stop the agent: btrfs snapshots are atomic + consistent to take
/// against a live subvolume. `label` is mandatory, must start with
/// `hive-`, and is otherwise restricted to `[A-Za-z0-9_-]` (no `.` at all
/// — 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(socket: &Path, name: &str, label: String) -> Result<()> {
if !agent_exists(name)? {
bail!("no agent named {name:?} (no state dir under the agents root)");
}
if !label.starts_with("hive-") {
bail!("snapshot label {label:?} must start with \"hive-\"");
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
{
bail!(
"snapshot label {label:?} must be [A-Za-z0-9_-] only (no \".\" — hive-priv rejects it)"
);
}
// 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: crate::util::parse_ident(name)?,
label,
},
"snapshot",
)
.await
}
/// `subvol snapshot delete <agent> <label>` — remove a snapshot created by
/// `subvol snapshot create`.
async fn subvol_snapshot_delete(socket: &Path, name: &str, label: &str) -> Result<()> {
daemon_request(
socket,
hive_host_sock::HostRequest::DeleteSnapshot {
name: crate::util::parse_ident(name)?,
label: label.to_owned(),
},
"snapshot delete",
)
.await?;
println!("deleted snapshot {label:?} for {name}");
Ok(())
}
/// `subvol snapshot send <agent> <label> [--parent <label>] --dest <file>` —
/// export a snapshot to a local file via `btrfs send`. `dest` must be a
/// bare filename (hive-priv rejects anything else via the credential-name
/// 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<()> {
// 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: crate::util::parse_ident(name)?,
label: label.to_owned(),
parent: parent.map(str::to_owned),
dest: dest.to_owned(),
},
"snapshot send",
)
.await
}