fix(#2391): mandatory hive- prefixed snapshot label, nest subvol snapshot create/delete

Per mara's PR review:
- snapshot label is now mandatory (was optional w/ timestamp default)
  and must start with "hive-" — hive-priv enforces this as an
  allow-list on top of the existing credential-name charset check, so
  only hivectl-issued labels can reach the btrfs shellout.
- nest under `subvol snapshot create`/`subvol snapshot delete`
  instead of othering delete as a separate top-level `delete-snapshot`
  verb.

Per argus's review:
- regenerate docs/tools/hivectl-cli.md (hivectl markdown-docs) to
  include the new subcommands — CI's hivectl-docs-fresh check compares
  this file against generated output.
This commit is contained in:
atlas 2026-07-14 18:26:12 +02:00 committed by mara
commit 8f8076b8ed
4 changed files with 106 additions and 32 deletions

View file

@ -620,22 +620,34 @@ enum SubvolCmd {
#[arg(long)]
yes: bool,
},
/// Create a read-only snapshot of an agent's state subvolume — the
/// first step of the (in-progress) inter-hive migration path, or a
/// manual point-in-time backup. Agent must already be a subvolume
/// (`subvol upgrade` first). Prints the snapshot's host path.
/// Read-only snapshots of an agent's state subvolume — the first step
/// of the (in-progress) inter-hive migration path, or a manual
/// point-in-time backup.
Snapshot {
#[command(subcommand)]
cmd: SnapshotCmd,
},
}
#[derive(Subcommand)]
enum SnapshotCmd {
/// Create a read-only snapshot. Agent must already be a subvolume
/// (`subvol upgrade` first). Prints the snapshot's host path.
Create {
/// Agent name (e.g. `damocles`, `iris`).
name: String,
/// Snapshot label (`[A-Za-z0-9_.-]`); defaults to a UTC timestamp.
/// Snapshot label. Mandatory, and must start with `hive-` — the
/// prefix doubles as an allow-list hive-priv checks so only
/// hivectl-issued snapshot names can reach the `btrfs subvolume
/// snapshot` shellout.
#[arg(long)]
label: Option<String>,
label: String,
},
/// Delete a snapshot created by `subvol snapshot`.
DeleteSnapshot {
/// Delete a snapshot created by `subvol snapshot create`.
Delete {
/// Agent name the snapshot belongs to.
name: String,
/// Snapshot label passed to `subvol snapshot --label`.
/// Snapshot label passed to `subvol snapshot create --label`.
label: String,
},
}
@ -711,10 +723,10 @@ async fn main() -> Result<()> {
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 { name, label } => subvol_snapshot(&name, label).await,
SubvolCmd::DeleteSnapshot { name, label } => {
subvol_delete_snapshot(&name, &label).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,
},
},
Cmd::Choom {
name,
@ -1725,21 +1737,20 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
Ok(())
}
/// `subvol snapshot <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. Default label is a unix-timestamp so repeated calls don't
/// collide without the caller having to think of a name.
async fn subvol_snapshot(name: &str, label: Option<String>) -> Result<()> {
/// `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 and must start with
/// `hive-` — hive-priv enforces the same prefix as an allow-list, 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<()> {
if !agent_exists(name)? {
bail!("no agent named {name:?} (no state dir under the agents root)");
}
let label = label.unwrap_or_else(|| {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
format!("snap-{secs}")
});
if !label.starts_with("hive-") {
bail!("snapshot label {label:?} must start with \"hive-\"");
}
let path = hive_c0re::priv_client::snapshot_agent_subvolume(name, &label)
.await
.with_context(|| format!("snapshot {name} state subvolume (label {label:?})"))?;
@ -1747,9 +1758,9 @@ async fn subvol_snapshot(name: &str, label: Option<String>) -> Result<()> {
Ok(())
}
/// `subvol delete-snapshot <agent> <label>` — remove a snapshot created by
/// `subvol snapshot`.
async fn subvol_delete_snapshot(name: &str, label: &str) -> Result<()> {
/// `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:?})"))?;