//! `hivectl quota` — per-agent disk accounting + optional quotas via //! btrfs qgroups. The daemon holds the privileged helper that reads / //! sets qgroups; hivectl relays the request and formats the reply. use std::path::Path; use anyhow::{Context as _, Result, bail}; use crate::util::daemon_request; /// `quota enable` — turn on btrfs qgroup accounting (operator opt-in). pub(crate) async fn quota_enable(socket: &Path) -> Result<()> { daemon_request(socket, hive_host_sock::HostRequest::QuotaEnable, "quota").await } /// `quota show [name]` — report per-agent disk usage from btrfs qgroups. /// The daemon resolves the agent set + reads each subvolume's usage (it /// holds the privileged helper); the client just formats the returned rows. pub(crate) async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()> { let resp = crate::client::request( socket, hive_host_sock::HostRequest::QuotaShow { name: name.map(crate::util::parse_ident).transpose()?, }, ) .await .with_context(|| format!("connect to daemon socket {}", socket.display()))?; if !resp.ok { // Carries the "btrfs quota not enabled — run `hivectl quota enable` // first" hint when qgroups are off. bail!("{}", resp.error.as_deref().unwrap_or("quota show failed")); } let rows = resp.quota.unwrap_or_default(); if rows.is_empty() { println!("no agents with a state dir found"); return Ok(()); } for row in &rows { match (row.referenced, row.exclusive) { (Some(rfer), Some(excl)) => println!( "{:<12} referenced {:>10} exclusive {:>10}", row.agent, human_bytes(rfer), human_bytes(excl) ), // Plain-dir agent (no qgroup) — the daemon set an explanatory note. _ => println!( "{:<12} ({})", row.agent, row.note.as_deref().unwrap_or("no qgroup data") ), } } Ok(()) } /// `quota limit ` — set or clear an agent's disk quota. pub(crate) async fn quota_limit(socket: &Path, name: &str, size: &str) -> Result<()> { let limit = parse_quota_size(size)?; daemon_request( socket, hive_host_sock::HostRequest::QuotaLimit { name: crate::util::parse_ident(name)?, limit, }, "quota", ) .await?; match limit { Some(n) => println!("set {name} quota to {} ({n} bytes)", human_bytes(n)), None => println!("cleared {name} quota"), } Ok(()) } /// Parse a quota size: a byte count, a `K`/`M`/`G`/`T`-suffixed value /// (powers of 1024), or `none` to clear. Rejects overflow + bad input. fn parse_quota_size(s: &str) -> Result> { let t = s.trim(); if t.eq_ignore_ascii_case("none") { return Ok(None); } let (num, mult) = match t.chars().last() { Some('K' | 'k') => (&t[..t.len() - 1], 1024u64), Some('M' | 'm') => (&t[..t.len() - 1], 1024 * 1024), Some('G' | 'g') => (&t[..t.len() - 1], 1024 * 1024 * 1024), Some('T' | 't') => (&t[..t.len() - 1], 1024u64 * 1024 * 1024 * 1024), _ => (t, 1), }; let n: u64 = num.trim().parse().with_context(|| { format!("invalid size {s:?} — use a byte count, a K/M/G/T suffix (e.g. 5G), or `none`") })?; n.checked_mul(mult) .map(Some) .with_context(|| format!("size {s:?} overflows u64")) } /// Format a byte count as a human-friendly KiB/MiB/GiB/TiB string. #[allow(clippy::cast_precision_loss)] // display-only; precision loss is cosmetic fn human_bytes(n: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; if n < 1024 { return format!("{n} B"); } let mut v = n as f64; let mut i = 0; while v >= 1024.0 && i < UNITS.len() - 1 { v /= 1024.0; i += 1; } format!("{v:.1} {}", UNITS[i]) }