feat(#1763): local-file half of btrfs send/receive migration transport
SendAgentSnapshotToFile priv op: btrfs send [-p <parent>] <snapshot> to a file under MIGRATE_STAGING_ROOT. Standalone-useful as a point-in-time snapshot export/backup today; the cross-hive ssh-piped leg (auth/trust design posted on #1763, awaiting mara/damocles steer) is a later, separate piece this doesn't block on. - hive-sh4re: PrivRequest::SendAgentSnapshotToFile + MIGRATE_STAGING_ROOT - hive-priv: validates names, refuses to overwrite an existing export, cleans up a partial file on btrfs send failure - hive-c0re: priv_client::send_agent_snapshot_to_file - hivectl: `hivectl subvol snapshot send <agent> <label> [--parent <label>] --dest <file>`
This commit is contained in:
parent
f5c8a6a8db
commit
90af0edab0
4 changed files with 201 additions and 2 deletions
|
|
@ -650,6 +650,26 @@ enum SnapshotCmd {
|
||||||
/// Snapshot label passed to `subvol snapshot create --label`.
|
/// Snapshot label passed to `subvol snapshot create --label`.
|
||||||
label: String,
|
label: String,
|
||||||
},
|
},
|
||||||
|
/// Export a snapshot to a local file via `btrfs send` (the local-file
|
||||||
|
/// half of inter-hive migration transport; the cross-hive `ssh ...
|
||||||
|
/// btrfs receive` leg isn't wired up yet). Also useful standalone as a
|
||||||
|
/// point-in-time backup: a full send with no `--parent` produces a
|
||||||
|
/// self-contained archive of the snapshot.
|
||||||
|
Send {
|
||||||
|
/// Agent name the snapshot belongs to.
|
||||||
|
name: String,
|
||||||
|
/// Snapshot label passed to `subvol snapshot create --label`.
|
||||||
|
label: String,
|
||||||
|
/// Optional parent snapshot label for an incremental send
|
||||||
|
/// (`btrfs send -p`) — must be an existing, older snapshot of the
|
||||||
|
/// same agent. Omit for a full send.
|
||||||
|
#[arg(long)]
|
||||||
|
parent: Option<String>,
|
||||||
|
/// Destination filename (not a path) under the migrate-staging
|
||||||
|
/// dir. Refused if it already exists.
|
||||||
|
#[arg(long)]
|
||||||
|
dest: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -726,6 +746,12 @@ async fn main() -> Result<()> {
|
||||||
SubvolCmd::Snapshot { cmd } => match cmd {
|
SubvolCmd::Snapshot { cmd } => match cmd {
|
||||||
SnapshotCmd::Create { name, label } => subvol_snapshot_create(&name, label).await,
|
SnapshotCmd::Create { name, label } => subvol_snapshot_create(&name, label).await,
|
||||||
SnapshotCmd::Delete { name, label } => subvol_snapshot_delete(&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::Choom {
|
Cmd::Choom {
|
||||||
|
|
@ -1777,6 +1803,24 @@ async fn subvol_snapshot_delete(name: &str, label: &str) -> Result<()> {
|
||||||
Ok(())
|
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(
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a hive-wide stop/start response: one `<verb>: <name>` line per
|
/// Render a hive-wide stop/start response: one `<verb>: <name>` line per
|
||||||
/// touched container, then surface any aggregated per-target failure as a
|
/// touched container, then surface any aggregated per-target failure as a
|
||||||
/// non-zero exit. `verb` is the past-tense word printed per item
|
/// non-zero exit. `verb` is the past-tense word printed per item
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,32 @@ pub async fn delete_agent_snapshot(agent_name: &str, snapshot_name: &str) -> Res
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream a read-only agent snapshot to a local file via `btrfs send`
|
||||||
|
/// (optionally incremental against `parent_snapshot_name`). Returns the
|
||||||
|
/// full path of the written file under `MIGRATE_STAGING_ROOT`. Via
|
||||||
|
/// hive-priv as root. See [`PrivRequest::SendAgentSnapshotToFile`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the hive-priv call fails, the snapshot (or parent)
|
||||||
|
/// doesn't exist, or the destination file already exists.
|
||||||
|
pub async fn send_agent_snapshot_to_file(
|
||||||
|
agent_name: &str,
|
||||||
|
snapshot_name: &str,
|
||||||
|
parent_snapshot_name: Option<&str>,
|
||||||
|
dest_file_name: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let (stdout, _) = check(
|
||||||
|
call(&PrivRequest::SendAgentSnapshotToFile {
|
||||||
|
agent_name: agent_name.to_owned(),
|
||||||
|
snapshot_name: snapshot_name.to_owned(),
|
||||||
|
parent_snapshot_name: parent_snapshot_name.map(str::to_owned),
|
||||||
|
dest_file_name: dest_file_name.to_owned(),
|
||||||
|
})
|
||||||
|
.await?,
|
||||||
|
)?;
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
/// Write `/etc/tmpfiles.d/hyperhive-agents.conf` for `agents` (logical names,
|
/// Write `/etc/tmpfiles.d/hyperhive-agents.conf` for `agents` (logical names,
|
||||||
/// e.g. `"atlas"`) and immediately apply it with `systemd-tmpfiles --create`.
|
/// e.g. `"atlas"`) and immediately apply it with `systemd-tmpfiles --create`.
|
||||||
/// See [`PrivRequest::SyncAgentTmpfiles`] for the full semantics.
|
/// See [`PrivRequest::SyncAgentTmpfiles`] for the full semantics.
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ use std::path::{Path, PathBuf};
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use hive_sh4re::priv_proto::{
|
use hive_sh4re::priv_proto::{
|
||||||
AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction,
|
AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction,
|
||||||
InfraContainer, JournalQuery, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest,
|
InfraContainer, JournalQuery, META_DIR, MIGRATE_STAGING_ROOT, NetworkIsolation, PRIV_SOCK,
|
||||||
PrivResponse, PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
|
PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
|
||||||
};
|
};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::unix::OwnedWriteHalf;
|
use tokio::net::unix::OwnedWriteHalf;
|
||||||
|
|
@ -383,6 +383,27 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
|
||||||
delete_agent_snapshot(agent_name, snapshot_name).await
|
delete_agent_snapshot(agent_name, snapshot_name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrivRequest::SendAgentSnapshotToFile {
|
||||||
|
ref agent_name,
|
||||||
|
ref snapshot_name,
|
||||||
|
ref parent_snapshot_name,
|
||||||
|
ref dest_file_name,
|
||||||
|
} => {
|
||||||
|
validate_agent_name(agent_name)?;
|
||||||
|
validate_snapshot_name(snapshot_name)?;
|
||||||
|
if let Some(parent) = parent_snapshot_name {
|
||||||
|
validate_snapshot_name(parent)?;
|
||||||
|
}
|
||||||
|
validate_credential_name(dest_file_name)?;
|
||||||
|
send_agent_snapshot_to_file(
|
||||||
|
agent_name,
|
||||||
|
snapshot_name,
|
||||||
|
parent_snapshot_name.as_deref(),
|
||||||
|
dest_file_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
PrivRequest::SyncAgentTmpfiles { ref agents } => sync_agent_tmpfiles(agents).await,
|
PrivRequest::SyncAgentTmpfiles { ref agents } => sync_agent_tmpfiles(agents).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1177,6 +1198,77 @@ async fn delete_agent_snapshot(agent_name: &str, snapshot_name: &str) -> Result<
|
||||||
Ok((String::new(), String::new()))
|
Ok((String::new(), String::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `SendAgentSnapshotToFile` — stream a read-only snapshot (optionally
|
||||||
|
/// incremental against `parent_name`) to a file under
|
||||||
|
/// `MIGRATE_STAGING_ROOT` via `btrfs send`. Local-file half of the
|
||||||
|
/// inter-hive migration transport; see `PrivRequest::SendAgentSnapshotToFile`
|
||||||
|
/// for the cross-hive follow-up.
|
||||||
|
async fn send_agent_snapshot_to_file(
|
||||||
|
agent_name: &str,
|
||||||
|
snapshot_name: &str,
|
||||||
|
parent_name: Option<&str>,
|
||||||
|
dest_file_name: &str,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
|
let snap = snapshot_path(agent_name, snapshot_name);
|
||||||
|
if !snap.exists() {
|
||||||
|
bail!(
|
||||||
|
"snapshot {} does not exist — create it with `subvol snapshot create` first",
|
||||||
|
snap.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(MIGRATE_STAGING_ROOT)
|
||||||
|
.with_context(|| format!("create {MIGRATE_STAGING_ROOT}"))?;
|
||||||
|
let dest = Path::new(MIGRATE_STAGING_ROOT).join(dest_file_name);
|
||||||
|
if dest.exists() {
|
||||||
|
bail!(
|
||||||
|
"{} already exists — pick a different destination or remove it first \
|
||||||
|
(send never overwrites an existing export)",
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let dest_file =
|
||||||
|
std::fs::File::create(&dest).with_context(|| format!("create {}", dest.display()))?;
|
||||||
|
|
||||||
|
let mut cmd = Command::new("btrfs");
|
||||||
|
cmd.arg("send");
|
||||||
|
if let Some(parent) = parent_name {
|
||||||
|
let parent_path = snapshot_path(agent_name, parent);
|
||||||
|
if !parent_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"parent snapshot {} does not exist — pick an existing parent or omit it for a full send",
|
||||||
|
parent_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cmd.arg("-p").arg(&parent_path);
|
||||||
|
}
|
||||||
|
cmd.arg(&snap);
|
||||||
|
cmd.stdout(std::process::Stdio::from(dest_file));
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
let out = cmd
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawn btrfs send {}", snap.display()))?
|
||||||
|
.wait_with_output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("wait on btrfs send {}", snap.display()))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
// Clean up a partial/failed export so a retry doesn't trip the
|
||||||
|
// "already exists" guard on garbage.
|
||||||
|
let _ = std::fs::remove_file(&dest);
|
||||||
|
bail!(
|
||||||
|
"btrfs send {} failed: {}",
|
||||||
|
snap.display(),
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
agent = %agent_name, snapshot = %snap.display(), dest = %dest.display(),
|
||||||
|
parent = ?parent_name, "exported agent snapshot to file"
|
||||||
|
);
|
||||||
|
Ok((dest.display().to_string(), String::new()))
|
||||||
|
}
|
||||||
|
|
||||||
/// `SetSubvolumeQuota` — set or clear a qgroup size limit on an agent
|
/// `SetSubvolumeQuota` — set or clear a qgroup size limit on an agent
|
||||||
/// subvolume (`btrfs qgroup limit <bytes|none> <…/agent_name>`). See the
|
/// subvolume (`btrfs qgroup limit <bytes|none> <…/agent_name>`). See the
|
||||||
/// wire doc.
|
/// wire doc.
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,13 @@ pub const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||||
/// across the crate.
|
/// across the crate.
|
||||||
pub const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
pub const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
||||||
|
|
||||||
|
/// Root of the local staging area for `btrfs send` archives
|
||||||
|
/// (`SendAgentSnapshotToFile`). A sibling of `AGENT_STATE_ROOT`, not inside
|
||||||
|
/// it — these are exported streams, not live/subvolume state, and don't
|
||||||
|
/// belong in the tree btrfs quota accounting or the subvolume-per-agent
|
||||||
|
/// layout cares about. Root-owned; hive-priv creates it on first use.
|
||||||
|
pub const MIGRATE_STAGING_ROOT: &str = "/var/lib/hyperhive/migrate-staging";
|
||||||
|
|
||||||
/// Output format for `ReadContainerJournal`. Maps to journalctl
|
/// Output format for `ReadContainerJournal`. Maps to journalctl
|
||||||
/// `--output=<...>`. Restricted to the two formats hive callers use so
|
/// `--output=<...>`. Restricted to the two formats hive callers use so
|
||||||
/// the wire type can't smuggle an arbitrary `--output` value.
|
/// the wire type can't smuggle an arbitrary `--output` value.
|
||||||
|
|
@ -616,6 +623,36 @@ pub enum PrivRequest {
|
||||||
snapshot_name: String,
|
snapshot_name: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Stream a previously-created read-only snapshot to a local file via
|
||||||
|
/// `btrfs send [-p <parent>] <snapshot> > <MIGRATE_STAGING_ROOT>/<dest_file_name>`.
|
||||||
|
/// The local-file half of the inter-hive migration transport: the
|
||||||
|
/// cross-hive leg (piping into `ssh <peer> btrfs receive`) is a later,
|
||||||
|
/// separate piece pending the auth/trust design — this variant is
|
||||||
|
/// useful standalone today as a point-in-time export/backup of a
|
||||||
|
/// snapshot (full send, no parent) or to validate the incremental
|
||||||
|
/// (`-p`) path locally before wiring up the network leg.
|
||||||
|
///
|
||||||
|
/// `dest_file_name` is a bare filename (not a path) under
|
||||||
|
/// `MIGRATE_STAGING_ROOT`, which hive-priv creates on first use.
|
||||||
|
/// Fails if the snapshot doesn't exist, `parent_snapshot_name` is given
|
||||||
|
/// but doesn't exist, or `dest_file_name` already exists (never
|
||||||
|
/// silently overwrites an export). Requires root.
|
||||||
|
SendAgentSnapshotToFile {
|
||||||
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
|
agent_name: String,
|
||||||
|
/// Snapshot label to send, same validation as `SnapshotAgentSubvolume`.
|
||||||
|
snapshot_name: String,
|
||||||
|
/// Optional parent snapshot label for an incremental
|
||||||
|
/// (`btrfs send -p`) send — must be an older read-only snapshot of
|
||||||
|
/// the same agent, still present on disk. `None` sends the full
|
||||||
|
/// snapshot.
|
||||||
|
parent_snapshot_name: Option<String>,
|
||||||
|
/// Bare filename (no path separators) for the exported stream,
|
||||||
|
/// written under `MIGRATE_STAGING_ROOT`. Same charset as a
|
||||||
|
/// credential name (`[A-Za-z0-9_-]`).
|
||||||
|
dest_file_name: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Write `/etc/tmpfiles.d/hyperhive-agents.conf` for the given agent set
|
/// Write `/etc/tmpfiles.d/hyperhive-agents.conf` for the given agent set
|
||||||
/// and immediately apply it with `systemd-tmpfiles --create`. Each entry
|
/// and immediately apply it with `systemd-tmpfiles --create`. Each entry
|
||||||
/// declares the per-agent runtime dirs (`/run/hyperhive/agents/<name>` and
|
/// declares the per-agent runtime dirs (`/run/hyperhive/agents/<name>` and
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue