From cf360b74b9ba77a4d8a3033fa282e1eb31b680ef Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 13 Nov 2026 16:00:30 +0100 Subject: [PATCH] remove the hivectl-only quota verbs and the trollshell status feed --- docs/2-handbook/hives/hivectl-cli.md | 59 +------ .../agent-state/persistence.md | 7 - hive-agent/src/todo_server.rs | 5 +- hive-c0re/src/container_view.rs | 15 +- hive-c0re/src/priv_client.rs | 86 +-------- hive-c0re/src/server.rs | 163 ++++-------------- hive-host-sock/src/lib.rs | 76 -------- hivectl/src/agents.rs | 13 +- hivectl/src/cli.rs | 31 +--- hivectl/src/main.rs | 2 - hivectl/src/quota.rs | 113 ------------ hivectl/src/util.rs | 2 +- 12 files changed, 51 insertions(+), 521 deletions(-) delete mode 100644 hivectl/src/quota.rs diff --git a/docs/2-handbook/hives/hivectl-cli.md b/docs/2-handbook/hives/hivectl-cli.md index f577b4c49..7eae6192a 100644 --- a/docs/2-handbook/hives/hivectl-cli.md +++ b/docs/2-handbook/hives/hivectl-cli.md @@ -16,9 +16,6 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agent rebuild`↴](#hivectl-agent-rebuild) * [`hivectl agent choom`↴](#hivectl-agent-choom) * [`hivectl agent watch`↴](#hivectl-agent-watch) -* [`hivectl agent quota`↴](#hivectl-agent-quota) -* [`hivectl agent quota show`↴](#hivectl-agent-quota-show) -* [`hivectl agent quota set`↴](#hivectl-agent-quota-set) * [`hivectl agent subvol`↴](#hivectl-agent-subvol) * [`hivectl agent subvol upgrade`↴](#hivectl-agent-subvol-upgrade) * [`hivectl agent subvol snapshot`↴](#hivectl-agent-subvol-snapshot) @@ -26,7 +23,6 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agent subvol snapshot delete`↴](#hivectl-agent-subvol-snapshot-delete) * [`hivectl agent subvol snapshot send`↴](#hivectl-agent-subvol-snapshot-send) * [`hivectl list-agents`↴](#hivectl-list-agents) -* [`hivectl quota-enable`↴](#hivectl-quota-enable) * [`hivectl wg`↴](#hivectl-wg) * [`hivectl wg init`↴](#hivectl-wg-init) * [`hivectl wg peer`↴](#hivectl-wg-peer) @@ -47,7 +43,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `list-agents` — Show all managed agents with their status and technical state -* `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem * `wg` — WireGuard inter-hive mesh setup helpers * `peer-config` — Generate the federation peer-config block for THIS hive * `stop` — Stop containers hive-wide in one operator action @@ -67,7 +62,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running. -Everything here targets a single named agent (`hivectl agent foo restart`, `hivectl agent foo choom`, …) — anything that acts hive-wide lives at the top level instead (`list-agents`, `restart`/`stop`/`start` with a scope, `quota-enable`). +Everything here targets a single named agent (`hivectl agent foo restart`, `hivectl agent foo choom`, …) — anything that acts hive-wide lives at the top level instead (`list-agents`, `restart`/`stop`/`start` with a scope). **Usage:** `hivectl agent ` @@ -83,7 +78,6 @@ Everything here targets a single named agent (`hivectl agent foo restart`, `hive * `rebuild` — Apply pending config to this managed container * `choom` — Open an interactive Claude session inside this agent's container * `watch` — Follow this agent's live turn/tool-call event stream from the CLI -* `quota` — This agent's disk accounting + optional quota via btrfs qgroups * `subvol` — btrfs subvolume management for this agent's state dir ###### **Arguments:** @@ -198,59 +192,24 @@ Prints one compact line per event as they happen. Control characters in agent-wr -## `hivectl agent quota` - -This agent's disk accounting + optional quota via btrfs qgroups. - -Needs `hivectl quota-enable` run once hive-wide first. No-op on non-btrfs hosts. - -**Usage:** `hivectl agent quota ` - -###### **Subcommands:** - -* `show` — Report this agent's disk usage from btrfs qgroups -* `set` — Set or clear this agent's disk-usage quota - - - -## `hivectl agent quota show` - -Report this agent's disk usage from btrfs qgroups - -**Usage:** `hivectl agent quota show` - - - -## `hivectl agent quota set` - -Set or clear this agent's disk-usage quota - -**Usage:** `hivectl agent quota set ` - -###### **Arguments:** - -* `` — Size cap (`5G`, `500M`, `1073741824`) or `none` to clear - - - ## `hivectl agent subvol` btrfs subvolume management for this agent's state dir. -Upgrade an existing plain-dir agent's state into a btrfs subvolume so it gains snapshots and per-subvol usage/quota. +Upgrade an existing plain-dir agent's state into a btrfs subvolume so it gains snapshots. **Usage:** `hivectl agent subvol ` ###### **Subcommands:** -* `upgrade` — Convert a plain-dir agent state root into a btrfs subvolume in place, so it gains snapshots and per-subvol usage/quota +* `upgrade` — Convert a plain-dir agent state root into a btrfs subvolume in place, so it gains snapshots * `snapshot` — Read-only snapshots of this agent's state subvolume ## `hivectl agent subvol upgrade` -Convert a plain-dir agent state root into a btrfs subvolume in place, so it gains snapshots and per-subvol usage/quota. +Convert a plain-dir agent state root into a btrfs subvolume in place, so it gains snapshots. Bounces the agent to migrate its state, so it requires `--yes`. @@ -331,16 +290,6 @@ Global — not scoped to one agent, so it lives at the top level rather than und -## `hivectl quota-enable` - -Enable btrfs qgroup accounting on the agent-state filesystem. - -Global one-shot toggle (not per-agent), hence top-level rather than under `hivectl agent `. Idempotent — safe to re-run. Once enabled, `hivectl agent quota show`/`quota set` work. - -**Usage:** `hivectl quota-enable` - - - ## `hivectl wg` WireGuard inter-hive mesh setup helpers. diff --git a/docs/4-implementation/agent-state/persistence.md b/docs/4-implementation/agent-state/persistence.md index addafed3b..600202f03 100644 --- a/docs/4-implementation/agent-state/persistence.md +++ b/docs/4-implementation/agent-state/persistence.md @@ -604,13 +604,6 @@ until an explicit opt-in upgrade. actually a subvolume, then the normal `remove_dir_all` sweep covers plain-dir agents. -Per-subvolume disk-usage accounting and optional quotas have since -landed as the qgroup work: `hivectl quota-enable` turns on btrfs -qgroup accounting hive-wide (opt-in, no-op on non-btrfs hosts), and -`hivectl agent quota show|set` reads/limits one agent's -subvolume usage through the same hive-priv-mediated path as -subvolume creation/deletion above. - The old snapshot store no longer exists; swarm-archive will replace it for agent-state backups, and nothing copies the subvolume off the host in the meantime. The subvolume is the whole agent state root, so `work/` is part of every diff --git a/hive-agent/src/todo_server.rs b/hive-agent/src/todo_server.rs index 1aadb48a8..144d25c81 100644 --- a/hive-agent/src/todo_server.rs +++ b/hive-agent/src/todo_server.rs @@ -451,9 +451,8 @@ fn compact(bus: &Bus, wake_prompt: Option) -> Response { message: "compact refused: effective context window is unknown (0)".to_owned(), }; } - // Token counts stay well under 2^53 in practice, so the f64 conversion - // is exact; this is a threshold ratio, not a byte-count display, but the - // same "cosmetic precision loss" reasoning as `hivectl::quota::human_bytes` applies. + // Token counts stay well under 2^53 in practice, so the f64 conversion is exact. + // The ratio is only compared against a fixed threshold, so a last-place rounding error cannot change the outcome. #[allow(clippy::cast_precision_loss)] let fraction = usage.context_tokens() as f64 / window as f64; if fraction < COMPACT_MIN_USAGE_FRACTION { diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 13520ec8a..35d4aaf6e 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -55,9 +55,7 @@ pub struct ContainerView { impl From for hive_sh4re::container::AgentStatusRow { /// The one place a `ContainerView` becomes the wire row `host.sock` - /// serves it as — `handle_agent_status`'s poll path and the - /// `SubscribeAgentStatus` push path both go through this, so the two - /// can never quietly diverge on which fields make the cut. + /// serves it as (`handle_agent_status`). /// /// `pending_reminders` has no source here: reminders are agent-local /// now, and c0re has no cross-agent visibility into pending counts @@ -97,8 +95,8 @@ fn agent_url(name: &str) -> Option { /// is testable without an env var in the loop. `/agent//` is the /// gateway's real path scheme /// (`nix/host-modules/hive-c0re/vhosts.nix`'s per-agent `agentLocations` -/// block) — computed once here so a client (trollshell's sidebar, the -/// dashboard) never has to derive it and silently drift if that scheme +/// block) — computed once here so a client (e.g. the dashboard) never has +/// to derive it and silently drift if that scheme /// ever changes. fn agent_url_for_domain(domain: &str, name: &str) -> String { format!("https://{domain}/agent/{name}/") @@ -362,10 +360,9 @@ mod tests { } } - /// The `From` impl both `handle_agent_status`'s poll path and - /// `stream_agent_status`'s push path go through — pins the field - /// mapping so the two can't quietly diverge, and that `port` (no - /// equivalent on the row) is dropped on purpose rather than by omission. + /// The `From` impl `handle_agent_status` goes through — pins the field + /// mapping, and that `port` (no equivalent on the row) is dropped on + /// purpose rather than by omission. #[test] fn agent_status_row_carries_every_field_the_row_has_and_drops_the_rest() { use super::ContainerView; diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 6cbcbe799..bbcd5add7 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -384,34 +384,6 @@ pub async fn delete_agent_subvolume(agent_name: &str) -> Result<()> { .await?) } -/// Enable btrfs qgroup accounting on the agent-state filesystem (operator -/// opt-in; prerequisite for usage reads + quotas). Idempotent; no-op off -/// btrfs. Via hive-priv (root). -/// -/// # Errors -/// Returns an error if the hive-priv call fails or `btrfs quota enable` -/// reports a non-zero exit. -pub async fn ensure_btrfs_quota() -> Result<()> { - ok(call(&PrivRequest::EnsureBtrfsQuota).await?) -} - -/// Read an agent state subvolume's btrfs qgroup usage, returning -/// `(referenced_bytes, exclusive_bytes)`. Via hive-priv (root). -/// -/// # Errors -/// Returns an error if the hive-priv call fails, `btrfs qgroup show` exits -/// non-zero (e.g. quota not enabled — the message propagates so the caller -/// can surface it), or the output has no level-0 qgroup row to parse. -pub async fn read_subvolume_usage(agent_name: &str) -> Result<(u64, u64)> { - let (stdout, _) = check( - call(&PrivRequest::ReadSubvolumeUsage { - agent_name: agent_name.to_owned(), - }) - .await?, - )?; - parse_qgroup_usage(&stdout).with_context(|| format!("parse qgroup usage for {agent_name}")) -} - /// How long hive-c0re waits for hive-priv's answer to a disk measurement: /// hive-priv's own bound plus a margin, so a hung hive-priv can't stall the /// caller while a slow walk still arrives as hive-priv's timeout error. @@ -450,20 +422,6 @@ async fn within( .map_err(|_| anyhow::anyhow!("hive-priv did not answer within {}s", limit.as_secs()))? } -/// Set or clear a btrfs qgroup size limit on an agent's state subvolume. -/// `limit_bytes = None` clears it. Requires quota enabled. Via hive-priv. -/// -/// # Errors -/// Returns an error if the hive-priv call fails or `btrfs qgroup limit` -/// reports a non-zero exit (e.g. quota not enabled). -pub async fn set_subvolume_quota(agent_name: &str, limit_bytes: Option) -> Result<()> { - ok(call(&PrivRequest::SetSubvolumeQuota { - agent_name: agent_name.to_owned(), - limit_bytes, - }) - .await?) -} - /// Convert an existing plain-dir agent state root into a btrfs subvolume in /// place (operator opt-in; via hive-priv as root). The caller must stop the /// agent first (so its state bind-mount is gone) and restart it after. @@ -553,29 +511,6 @@ fn send_snapshot_request( } } -/// Parse `(referenced, exclusive)` bytes from `btrfs qgroup show -f --raw` -/// output (a qgroup row is ` …`). -/// -/// `-f ` already restricts the listing to qgroups impacting that path -/// (excluding ancestral qgroups — see btrfs-qgroup-show(8)), so it never -/// mixes in other agents' subvolumes. Among the rows it returns we select -/// the **level-0** qgroup (`0/`) — the subvolume's own automatic -/// usage qgroup — rather than blindly taking the last line. Picking the -/// `0/` leaf is unambiguous even if an operator has assigned the subvolume -/// to a higher-level aggregate qgroup (`1/`, …) that `-F` would surface. -fn parse_qgroup_usage(out: &str) -> Result<(u64, u64)> { - for line in out.lines() { - let cols: Vec<&str> = line.split_whitespace().collect(); - if cols.len() >= 3 - && cols[0].starts_with("0/") - && let (Ok(rfer), Ok(excl)) = (cols[1].parse::(), cols[2].parse::()) - { - return Ok((rfer, excl)); - } - } - bail!("no level-0 qgroup data row in `btrfs qgroup show` output: {out:?}") -} - fn check(resp: PrivResponse) -> Result<(String, String)> { if resp.ok { Ok((resp.stdout, resp.stderr)) @@ -597,8 +532,8 @@ mod tests { use super::{ BindMount, CredentialMount, HiveFacts, NetworkIsolation, NixBuilds, PrivRequest, PrivResponse, PrivStream, call_at, call_streaming_at, check, create_container_request, - older_hive_priv_refused, parse_qgroup_usage, resource_limits_request, - send_snapshot_request, update_container_request, within, write_nspawn_flags_request, + older_hive_priv_refused, resource_limits_request, send_snapshot_request, + update_container_request, within, write_nspawn_flags_request, }; use anyhow::Context as _; use serde_json::{Value, json}; @@ -748,23 +683,6 @@ mod tests { assert_eq!(incremental["snapshot_name"], "snap-2"); } - #[test] - fn the_level_zero_qgroup_row_gives_the_usage() { - let out = "qgroupid rfer excl\n\ - -------- ---- ----\n\ - 1/100 999 888\n\ - 0/257 4096 2048\n"; - assert_eq!(parse_qgroup_usage(out).expect("a 0/ row"), (4096, 2048)); - } - - #[test] - fn output_with_no_level_zero_row_is_an_error() { - let err = parse_qgroup_usage("qgroupid rfer excl\n1/100 1 2\n").expect_err("no 0/ row"); - assert!(err.to_string().contains("no level-0 qgroup"), "{err}"); - // A 0/ row whose columns are not numbers is skipped, not misread. - parse_qgroup_usage("0/257 x y\n").expect_err("non-numeric columns"); - } - #[test] fn a_refused_response_surfaces_the_error_text() { let refused = PrivResponse { diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 137d2bc7b..fb8a62af0 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -93,21 +93,12 @@ async fn handle_with( continue; } }; - // The one verb that answers with more than one response — see its - // doc comment. Hands `write` over and stops reading further - // requests on this connection; a client wanting anything else - // opens a fresh one. - if matches!(req, HostRequest::SubscribeAgentStatus) { - return stream_agent_status(write, coord).await; - } let resp = dispatch(&req, coord.clone()).await; write_response(&mut write, &resp).await?; } } -/// Serialize one `HostResponse` as a JSON line and flush it. Shared by the -/// ordinary one-response-per-request path and `stream_agent_status`'s -/// multi-response one, so the two can't quietly drift on framing. +/// Serialize one `HostResponse` as a JSON line and flush it. async fn write_response( write: &mut tokio::net::unix::OwnedWriteHalf, resp: &HostResponse, @@ -167,25 +158,11 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // see the request's doc comment for why the client asks. HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?), HostRequest::AgentStatus => handle_agent_status().await, - // Intercepted in `handle()` before a request ever reaches - // `dispatch` — see `stream_agent_status`'s doc comment. This - // arm exists only so the match stays exhaustive; reaching it - // would mean a future caller invoked `dispatch` directly. - HostRequest::SubscribeAgentStatus => { - HostResponse::error("SubscribeAgentStatus is handled by the connection loop") - } // The hive domain + per-surface public URLs are injected into // c0re's service env by the hyperhive module; surface them so the // operator CLI can fill in this hive's own identity (the // federation peer-config block). HostRequest::Urls => HostResponse::urls(hive_urls()), - HostRequest::QuotaEnable => handle_quota_enable().await?, - HostRequest::QuotaLimit { name, limit } => { - handle_quota_limit(name.as_str(), *limit).await? - } - HostRequest::QuotaShow { name } => { - handle_quota_show(name.as_ref().map(hive_types::Ident::as_str)).await? - } HostRequest::UpgradeSubvolume { name } => { handle_upgrade_subvolume(name.as_str()).await? } @@ -264,49 +241,6 @@ async fn handle_agent_status() -> HostResponse { } } -/// `SubscribeAgentStatus` — ack once, then push one single-row -/// [`HostResponse::agent_statuses`] per agent-status change for as long as -/// the client stays connected. Consumes `write` (rather than borrowing it, -/// like every other handler here) because this is the one verb that keeps -/// writing after its own response, so `handle()` hands over ownership and -/// stops reading further requests on this connection once it calls this. -/// -/// Rides the same `Coordinator.dashboard_events` broadcast channel the -/// dashboard's own SSE route reads (`dashboard/state_snapshot.rs`) — -/// `SetStatus` already triggers a `rescan_containers_and_emit` on every -/// status change (`socket_server/mod.rs::handle_set_status`), so that -/// channel already carries every event this needs; no separate plumbing. -async fn stream_agent_status( - mut write: tokio::net::unix::OwnedWriteHalf, - coord: Arc, -) -> Result<()> { - let mut events = coord.dashboard_subscribe(); - write_response(&mut write, &HostResponse::success()).await?; - loop { - match events.recv().await { - Ok(crate::dashboard_events::DashboardEvent::ContainerStateChanged { - container, - .. - }) => { - let row = hive_sh4re::container::AgentStatusRow::from(container); - write_response(&mut write, &HostResponse::agent_statuses(vec![row])).await?; - } - // Every other event kind on this channel is dashboard-only - // (broker traffic, the queue, …) — nothing this - // subscriber asked for. - Ok(_) => {} - // Best-effort, same contract the dashboard's own live channel - // already has (see `HostRequest::SubscribeAgentStatus`'s doc - // comment) — a slow reader drops updates rather than stalling - // the broadcaster for everyone else on the channel. - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - tracing::warn!(skipped, "agent-status subscribe: receiver lagged"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => return Ok(()), - } - } -} - /// True when `name` has a state dir under the agents root, i.e. it's a /// managed agent rather than a bare (operator/human) account. /// @@ -320,67 +254,6 @@ fn agent_exists(name: &hive_types::Ident) -> Result { .with_context(|| format!("check agent state dir for {name}")) } -async fn handle_quota_enable() -> Result { - crate::priv_client::ensure_btrfs_quota() - .await - .context("enable btrfs qgroup accounting")?; - Ok(HostResponse::messages(vec![ - "btrfs qgroup accounting enabled on the agent-state filesystem.".to_owned(), - "(usage may read 0 until btrfs finishes its background rescan)".to_owned(), - ])) -} - -async fn handle_quota_limit(name: &str, limit: Option) -> Result { - crate::priv_client::set_subvolume_quota(name, limit) - .await - .with_context(|| format!("set quota for {name}"))?; - // Bare success: the client prints the human-readable confirmation from - // the value it sent (it holds the `human_bytes` formatter). - Ok(HostResponse::success()) -} - -async fn handle_quota_show(name: Option<&str>) -> Result { - let agents: Vec = match name { - Some(n) => vec![n.to_owned()], - None => Coordinator::kept_state_names() - .into_iter() - .map(hive_types::Ident::into_string) - .collect(), - }; - let mut rows = Vec::with_capacity(agents.len()); - for agent in &agents { - match crate::priv_client::read_subvolume_usage(agent).await { - Ok((referenced, exclusive)) => rows.push(hive_host_sock::QuotaRow { - agent: agent.clone(), - referenced: Some(referenced), - exclusive: Some(exclusive), - note: None, - }), - Err(e) => { - let msg = format!("{e:#}"); - // btrfs-progs prints "ERROR: ... quota not enabled" to stderr - // when qgroups are off; short-circuit the whole sweep with the - // enable hint (case-insensitive fragment match — the wording - // varies across btrfs-progs versions). - if msg.to_ascii_lowercase().contains("quota not enabled") { - return Ok(HostResponse::error( - "btrfs quota not enabled — run `hivectl quota-enable` first", - )); - } - // A plain-dir agent (no subvolume) has no qgroup; note it - // inline and keep going rather than aborting the whole sweep. - rows.push(hive_host_sock::QuotaRow { - agent: agent.clone(), - referenced: None, - exclusive: None, - note: Some(format!("no qgroup data — plain dir or: {msg}")), - }); - } - } - } - Ok(HostResponse::quota(rows)) -} - async fn handle_upgrade_subvolume(name: &str) -> Result { crate::priv_client::upgrade_agent_subvolume(name) .await @@ -748,12 +621,34 @@ mod tests { /// `parse error` by the connection loop) while a served verb parses. #[test] fn a_removed_verb_is_a_parse_error_and_a_served_one_parses() { - let err = - serde_json::from_slice::(br#"{"cmd":"list"}"#).expect_err("list is gone"); - assert!(err.to_string().contains("unknown variant `list`"), "{err}"); - assert!( - serde_json::from_slice::(br#"{"cmd":"subscribe_agent_status"}"#).is_ok() - ); + for gone in [ + "list", + "subscribe_agent_status", + "quota_enable", + "quota_limit", + "quota_show", + ] { + let line = format!(r#"{{"cmd":"{gone}"}}"#); + let err = serde_json::from_slice::(line.as_bytes()).expect_err(gone); + assert!( + err.to_string() + .contains(&format!("unknown variant `{gone}`")), + "{gone}: {err}" + ); + } + for served in [ + br#"{"cmd":"agent_status"}"#.as_slice(), + br#"{"cmd":"upgrade_subvolume","name":"atlas"}"#, + br#"{"cmd":"snapshot_subvolume","name":"atlas","label":"hive-x"}"#, + br#"{"cmd":"delete_snapshot","name":"atlas","label":"hive-x"}"#, + br#"{"cmd":"send_snapshot","name":"atlas","label":"hive-x","dest":"out"}"#, + ] { + assert!( + serde_json::from_slice::(served).is_ok(), + "{}", + String::from_utf8_lossy(served) + ); + } } /// A real `Coordinator` over a throwaway sqlite dir; `handle_with` takes diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 8eadac25f..baec3b185 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -155,22 +155,6 @@ pub enum HostRequest { /// pending reminders) — the `hivectl list-agents` roster view. /// Reuses the dashboard's per-agent `ContainerView` aggregation. AgentStatus, - /// Turn this connection into a live push feed of agent-status changes, - /// so a polling loop against [`HostRequest::AgentStatus`] is no longer - /// the only way to notice one. The one verb on this socket that answers - /// with more than one response: the server acks with a bare - /// [`HostResponse::success()`] first, then keeps the connection open - /// and writes one further [`HostResponse::agent_statuses`] (always a - /// single-row `Vec`) per changed agent, for as long as the client stays - /// connected. No further requests are read on this connection after - /// this one — open a fresh connection for anything else. - /// - /// Best-effort, same contract the dashboard's own live channel already - /// has: a slow reader can miss updates rather than back-pressuring the - /// daemon, so a client that cares about the *current* state after a gap - /// should still fall back to a fresh [`HostRequest::AgentStatus`] poll, - /// not assume the stream alone is authoritative. - SubscribeAgentStatus, /// Report this hive's canonical DNS domain /// (`services.hyperhive.domain`) plus the browser-facing home / /// forge / matrix URLs, daemon-sourced so custom forge/matrix @@ -210,32 +194,6 @@ pub enum HostRequest { #[serde(default)] scope: LifecycleScope, }, - /// Turn on btrfs qgroup accounting on the agent-state filesystem, via - /// the privileged helper. Daemon-side equivalent of `hivectl - /// quota-enable`. Returns advisory lines in [`HostResponse::messages`]. - QuotaEnable, - /// Set (or, with `limit = None`, clear) an agent's btrfs disk quota via - /// the privileged helper. Daemon-side equivalent of `hivectl agent - /// quota set`; the size string is parsed to bytes client-side. - /// Returns a bare success — the client prints the confirmation from - /// the value it sent. - QuotaLimit { - name: Ident, - #[serde(default)] - limit: Option, - }, - /// Report btrfs qgroup usage (`hivectl agent quota show`, or all - /// kept state dirs when `name` is absent — no CLI path reaches the - /// absent case today, but the daemon still supports it). The daemon - /// resolves the agent set and reads each subvolume's referenced/ - /// exclusive usage via the privileged helper. Result rows land in - /// [`HostResponse::quota`]; a "btrfs quota not enabled" error - /// short-circuits the whole sweep as a plain [`HostResponse::error`] - /// so the client can print the enable hint. - QuotaShow { - #[serde(default)] - name: Option, - }, /// Migrate an agent's plain state dir to a btrfs subvolume via the /// privileged helper (`hivectl agent subvol upgrade`). The agent /// MUST already be stopped — the client orchestrates stop → this → @@ -263,25 +221,6 @@ pub enum HostRequest { }, } -/// One agent's btrfs qgroup usage row — the [`HostRequest::QuotaShow`] -/// result unit. `referenced` / `exclusive` are byte counts when the agent -/// has a live qgroup; both are `None` (with an explanatory `note`) for a -/// plain-dir agent that has no subvolume to account. The client formats the -/// byte counts into human-readable columns. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QuotaRow { - pub agent: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub referenced: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exclusive: Option, - /// Set instead of the byte counts when the agent has no qgroup data - /// (plain dir, or a non-"quota not enabled" read error), so the client - /// can print an inline explanation and keep going. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note: Option, -} - /// Selects which container classes a hive-wide [`HostRequest::Stop`] / /// [`HostRequest::Start`] touches. An all-false scope means **everything** /// (the bare `hivectl stop` / `start`); set individual fields to restrict @@ -401,10 +340,6 @@ pub struct HostResponse { /// Empty for requests that produce no such output. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub messages: Vec, - /// `QuotaShow` result — one row per agent with its btrfs qgroup usage. - /// `None` for every other request kind. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quota: Option>, } /// Hand-written rather than `#[derive(Default)]` for one reason: @@ -426,7 +361,6 @@ impl Default for HostResponse { queued_dags: None, nodes: None, messages: Vec::new(), - quota: None, } } } @@ -520,14 +454,4 @@ impl HostResponse { ..Self::default() } } - - /// `QuotaShow` result — one usage row per agent. - #[must_use] - pub fn quota(rows: Vec) -> Self { - Self { - ok: true, - quota: Some(rows), - ..Self::default() - } - } } diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index 26f2b6e58..a07dee2d4 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -1,7 +1,7 @@ //! `hivectl agent ` — everything scoped to ONE managed agent: //! container lifecycle over the host admin socket (restart/pause/resume/ //! spawn/kill/destroy/rebuild/choom/watch), plus the -//! `quota` and `subvol` groups, whose handlers live in their own modules. +//! `subvol` group, whose handlers live in their own module. //! `agents_list` (`hivectl list-agents`) is the one genuinely hive-wide //! read that lives in this module too since it shares the same daemon //! request as everything else here, even though it's dispatched from a @@ -12,7 +12,7 @@ use std::path::Path; use anyhow::{Context as _, Result, bail}; use hive_host_sock::HostRequest; -use crate::cli::{AgentCmd, AgentQuotaCmd}; +use crate::cli::AgentCmd; use crate::dag_progress::wait_for_nodes; use crate::util::render; @@ -282,13 +282,8 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul crate::choom::choom(socket, name, resume_session.as_deref()).await } AgentCmd::Watch => crate::watch::watch(socket, name).await, - // `quota` and `subvol` keep their own modules — these arms are - // just the reparenting glue that hoists `name` in from the - // parent `agent ` command. - AgentCmd::Quota { cmd } => match cmd { - AgentQuotaCmd::Show => crate::quota::quota_show(socket, name).await, - AgentQuotaCmd::Set { size } => crate::quota::quota_limit(socket, name, &size).await, - }, + // `subvol` keeps its own module — this arm is just the reparenting + // glue that hoists `name` in from the parent `agent ` command. AgentCmd::Subvol { cmd } => crate::subvol::dispatch_subvol(socket, name, cmd).await, } } diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 5e5a33767..508c3148d 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -33,7 +33,7 @@ pub enum Cmd { /// Everything here targets a single named agent (`hivectl agent foo /// restart`, `hivectl agent foo choom`, …) — anything that acts /// hive-wide lives at the top level instead (`list-agents`, - /// `restart`/`stop`/`start` with a scope, `quota-enable`). + /// `restart`/`stop`/`start` with a scope). Agent { /// Agent name (for example `agent1`). name: String, @@ -51,12 +51,6 @@ pub enum Cmd { #[arg(long)] json: bool, }, - /// Enable btrfs qgroup accounting on the agent-state filesystem. - /// - /// Global one-shot toggle (not per-agent), hence top-level rather than - /// under `hivectl agent `. Idempotent — safe to re-run. Once - /// enabled, `hivectl agent quota show`/`quota set` work. - QuotaEnable, /// WireGuard inter-hive mesh setup helpers. /// /// Generate this hive's mesh key and print the nix to enable the mesh, @@ -277,39 +271,20 @@ pub enum AgentCmd { /// ends the watch with exit 0. `Ctrl-C` to stop. /// Requires the agent to be running. Watch, - /// This agent's disk accounting + optional quota via btrfs qgroups. - /// - /// Needs `hivectl quota-enable` run once hive-wide first. No-op on - /// non-btrfs hosts. - Quota { - #[command(subcommand)] - cmd: AgentQuotaCmd, - }, /// btrfs subvolume management for this agent's state dir. /// /// Upgrade an existing plain-dir agent's state into a btrfs subvolume - /// so it gains snapshots and per-subvol usage/quota. + /// so it gains snapshots. Subvol { #[command(subcommand)] cmd: SubvolCmd, }, } -#[derive(Subcommand)] -pub enum AgentQuotaCmd { - /// Report this agent's disk usage from btrfs qgroups. - Show, - /// Set or clear this agent's disk-usage quota. - Set { - /// Size cap (`5G`, `500M`, `1073741824`) or `none` to clear. - size: String, - }, -} - #[derive(Subcommand)] pub enum SubvolCmd { /// Convert a plain-dir agent state root into a btrfs subvolume in - /// place, so it gains snapshots and per-subvol usage/quota. + /// place, so it gains snapshots. /// /// Bounces the agent to migrate its state, so it requires `--yes`. Upgrade { diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index 6bdc7b672..19fab6eb1 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -28,7 +28,6 @@ mod client; mod dag_progress; use cli::{Cli, Cmd, WgCmd}; mod completions; -mod quota; mod util; use completions::generate_completions; mod wg; @@ -54,7 +53,6 @@ async fn main() -> Result<()> { match cli.cmd { Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await, Cmd::ListAgents { json } => agents_list(&socket, json).await, - Cmd::QuotaEnable => quota::quota_enable(&socket).await, Cmd::Wg { cmd } => match cmd { WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await, WgCmd::Peer { diff --git a/hivectl/src/quota.rs b/hivectl/src/quota.rs deleted file mode 100644 index 9d6178ce9..000000000 --- a/hivectl/src/quota.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! `hivectl quota-enable` + `hivectl agent 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 -} - -/// `agent quota show` — report one agent's disk usage from btrfs -/// qgroups. The daemon reads that subvolume's usage (it holds the -/// privileged helper); the client just formats the returned row(s). -pub(crate) async fn quota_show(socket: &Path, name: &str) -> Result<()> { - let resp = crate::client::request( - socket, - hive_host_sock::HostRequest::QuotaShow { - name: Some(crate::util::parse_ident(name)?), - }, - ) - .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]) -} diff --git a/hivectl/src/util.rs b/hivectl/src/util.rs index 628f48190..57cfe6a4d 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -18,7 +18,7 @@ pub(crate) fn parse_ident(name: &str) -> Result { /// Send a provisioning request to the daemon and print its result lines. /// The daemon owns the provisioning logic; hivectl just relays the outcome, -/// prefixing any error with `label` (e.g. `forge` / `quota`). +/// prefixing any error with `label` (e.g. `forge`). pub(crate) async fn daemon_request( socket: &Path, req: hive_host_sock::HostRequest,