//! `hivectl agent 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_nodes; 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 ` — 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 agent 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. `name` is hoisted in from the parent `agent ` command. pub(crate) async fn dispatch_subvol(socket: &Path, name: &str, cmd: SubvolCmd) -> Result<()> { match cmd { SubvolCmd::Upgrade { yes } => subvol_upgrade(socket, name, yes).await, SubvolCmd::Snapshot { cmd } => match cmd { SnapshotCmd::Create { label } => subvol_snapshot_create(socket, name, label).await, SnapshotCmd::Delete { label } => subvol_snapshot_delete(socket, name, &label).await, SnapshotCmd::Send { label, parent, dest, } => subvol_snapshot_send(socket, name, &label, parent.as_deref(), &dest).await, SnapshotCmd::Push { label, parent } => { subvol_snapshot_push(socket, name, &label, parent.as_deref()).await } }, } } async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { if !agent_exists(socket, name).await? { 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 job 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_nodes(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_nodes(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 --label