split hivectl main.rs into per-domain modules (#2509)
This commit is contained in:
parent
673aea4e50
commit
fc7720572b
16 changed files with 1922 additions and 1771 deletions
222
hivectl/src/subvol.rs
Normal file
222
hivectl/src/subvol.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! `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: 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
|
||||
// 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: name.to_owned(),
|
||||
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: name.to_owned(),
|
||||
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: name.to_owned(),
|
||||
label: label.to_owned(),
|
||||
parent: parent.map(str::to_owned),
|
||||
dest: dest.to_owned(),
|
||||
},
|
||||
"snapshot send",
|
||||
)
|
||||
.await
|
||||
}
|
||||
Loading…
Reference in a new issue