remove the hivectl-only quota verbs and the trollshell status feed
This commit is contained in:
parent
70a8e54038
commit
cf360b74b9
12 changed files with 51 additions and 521 deletions
|
|
@ -16,9 +16,6 @@ This document contains the help content for the `hivectl` command-line program.
|
||||||
* [`hivectl agent rebuild`↴](#hivectl-agent-rebuild)
|
* [`hivectl agent rebuild`↴](#hivectl-agent-rebuild)
|
||||||
* [`hivectl agent choom`↴](#hivectl-agent-choom)
|
* [`hivectl agent choom`↴](#hivectl-agent-choom)
|
||||||
* [`hivectl agent watch`↴](#hivectl-agent-watch)
|
* [`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`↴](#hivectl-agent-subvol)
|
||||||
* [`hivectl agent subvol upgrade`↴](#hivectl-agent-subvol-upgrade)
|
* [`hivectl agent subvol upgrade`↴](#hivectl-agent-subvol-upgrade)
|
||||||
* [`hivectl agent subvol snapshot`↴](#hivectl-agent-subvol-snapshot)
|
* [`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 delete`↴](#hivectl-agent-subvol-snapshot-delete)
|
||||||
* [`hivectl agent subvol snapshot send`↴](#hivectl-agent-subvol-snapshot-send)
|
* [`hivectl agent subvol snapshot send`↴](#hivectl-agent-subvol-snapshot-send)
|
||||||
* [`hivectl list-agents`↴](#hivectl-list-agents)
|
* [`hivectl list-agents`↴](#hivectl-list-agents)
|
||||||
* [`hivectl quota-enable`↴](#hivectl-quota-enable)
|
|
||||||
* [`hivectl wg`↴](#hivectl-wg)
|
* [`hivectl wg`↴](#hivectl-wg)
|
||||||
* [`hivectl wg init`↴](#hivectl-wg-init)
|
* [`hivectl wg init`↴](#hivectl-wg-init)
|
||||||
* [`hivectl wg peer`↴](#hivectl-wg-peer)
|
* [`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
|
* `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
|
* `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
|
* `wg` — WireGuard inter-hive mesh setup helpers
|
||||||
* `peer-config` — Generate the federation peer-config block for THIS hive
|
* `peer-config` — Generate the federation peer-config block for THIS hive
|
||||||
* `stop` — Stop containers hive-wide in one operator action
|
* `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.
|
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 <NAME> <COMMAND>`
|
**Usage:** `hivectl agent <NAME> <COMMAND>`
|
||||||
|
|
||||||
|
|
@ -83,7 +78,6 @@ Everything here targets a single named agent (`hivectl agent foo restart`, `hive
|
||||||
* `rebuild` — Apply pending config to this managed container
|
* `rebuild` — Apply pending config to this managed container
|
||||||
* `choom` — Open an interactive Claude session inside this agent's 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
|
* `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
|
* `subvol` — btrfs subvolume management for this agent's state dir
|
||||||
|
|
||||||
###### **Arguments:**
|
###### **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 <COMMAND>`
|
|
||||||
|
|
||||||
###### **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 <SIZE>`
|
|
||||||
|
|
||||||
###### **Arguments:**
|
|
||||||
|
|
||||||
* `<SIZE>` — Size cap (`5G`, `500M`, `1073741824`) or `none` to clear
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## `hivectl agent subvol`
|
## `hivectl agent subvol`
|
||||||
|
|
||||||
btrfs subvolume management for this agent's state dir.
|
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 <COMMAND>`
|
**Usage:** `hivectl agent subvol <COMMAND>`
|
||||||
|
|
||||||
###### **Subcommands:**
|
###### **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
|
* `snapshot` — Read-only snapshots of this agent's state subvolume
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## `hivectl agent subvol upgrade`
|
## `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`.
|
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 <name>`. Idempotent — safe to re-run. Once enabled, `hivectl agent <name> quota show`/`quota set` work.
|
|
||||||
|
|
||||||
**Usage:** `hivectl quota-enable`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## `hivectl wg`
|
## `hivectl wg`
|
||||||
|
|
||||||
WireGuard inter-hive mesh setup helpers.
|
WireGuard inter-hive mesh setup helpers.
|
||||||
|
|
|
||||||
|
|
@ -604,13 +604,6 @@ until an explicit opt-in upgrade.
|
||||||
actually a subvolume, then the normal `remove_dir_all` sweep covers
|
actually a subvolume, then the normal `remove_dir_all` sweep covers
|
||||||
plain-dir agents.
|
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 <name> 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 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
|
The subvolume is the whole agent state root, so `work/` is part of every
|
||||||
|
|
|
||||||
|
|
@ -451,9 +451,8 @@ fn compact(bus: &Bus, wake_prompt: Option<String>) -> Response {
|
||||||
message: "compact refused: effective context window is unknown (0)".to_owned(),
|
message: "compact refused: effective context window is unknown (0)".to_owned(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Token counts stay well under 2^53 in practice, so the f64 conversion
|
// Token counts stay well under 2^53 in practice, so the f64 conversion is exact.
|
||||||
// is exact; this is a threshold ratio, not a byte-count display, but the
|
// The ratio is only compared against a fixed threshold, so a last-place rounding error cannot change the outcome.
|
||||||
// same "cosmetic precision loss" reasoning as `hivectl::quota::human_bytes` applies.
|
|
||||||
#[allow(clippy::cast_precision_loss)]
|
#[allow(clippy::cast_precision_loss)]
|
||||||
let fraction = usage.context_tokens() as f64 / window as f64;
|
let fraction = usage.context_tokens() as f64 / window as f64;
|
||||||
if fraction < COMPACT_MIN_USAGE_FRACTION {
|
if fraction < COMPACT_MIN_USAGE_FRACTION {
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,7 @@ pub struct ContainerView {
|
||||||
|
|
||||||
impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
|
impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
|
||||||
/// The one place a `ContainerView` becomes the wire row `host.sock`
|
/// The one place a `ContainerView` becomes the wire row `host.sock`
|
||||||
/// serves it as — `handle_agent_status`'s poll path and the
|
/// serves it as (`handle_agent_status`).
|
||||||
/// `SubscribeAgentStatus` push path both go through this, so the two
|
|
||||||
/// can never quietly diverge on which fields make the cut.
|
|
||||||
///
|
///
|
||||||
/// `pending_reminders` has no source here: reminders are agent-local
|
/// `pending_reminders` has no source here: reminders are agent-local
|
||||||
/// now, and c0re has no cross-agent visibility into pending counts
|
/// now, and c0re has no cross-agent visibility into pending counts
|
||||||
|
|
@ -97,8 +95,8 @@ fn agent_url(name: &str) -> Option<String> {
|
||||||
/// is testable without an env var in the loop. `/agent/<name>/` is the
|
/// is testable without an env var in the loop. `/agent/<name>/` is the
|
||||||
/// gateway's real path scheme
|
/// gateway's real path scheme
|
||||||
/// (`nix/host-modules/hive-c0re/vhosts.nix`'s per-agent `agentLocations`
|
/// (`nix/host-modules/hive-c0re/vhosts.nix`'s per-agent `agentLocations`
|
||||||
/// block) — computed once here so a client (trollshell's sidebar, the
|
/// block) — computed once here so a client (e.g. the dashboard) never has
|
||||||
/// dashboard) never has to derive it and silently drift if that scheme
|
/// to derive it and silently drift if that scheme
|
||||||
/// ever changes.
|
/// ever changes.
|
||||||
fn agent_url_for_domain(domain: &str, name: &str) -> String {
|
fn agent_url_for_domain(domain: &str, name: &str) -> String {
|
||||||
format!("https://{domain}/agent/{name}/")
|
format!("https://{domain}/agent/{name}/")
|
||||||
|
|
@ -362,10 +360,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `From` impl both `handle_agent_status`'s poll path and
|
/// The `From` impl `handle_agent_status` goes through — pins the field
|
||||||
/// `stream_agent_status`'s push path go through — pins the field
|
/// mapping, and that `port` (no equivalent on the row) is dropped on
|
||||||
/// mapping so the two can't quietly diverge, and that `port` (no
|
/// purpose rather than by omission.
|
||||||
/// equivalent on the row) is dropped on purpose rather than by omission.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_status_row_carries_every_field_the_row_has_and_drops_the_rest() {
|
fn agent_status_row_carries_every_field_the_row_has_and_drops_the_rest() {
|
||||||
use super::ContainerView;
|
use super::ContainerView;
|
||||||
|
|
|
||||||
|
|
@ -384,34 +384,6 @@ pub async fn delete_agent_subvolume(agent_name: &str) -> Result<()> {
|
||||||
.await?)
|
.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:
|
/// 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
|
/// 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.
|
/// caller while a slow walk still arrives as hive-priv's timeout error.
|
||||||
|
|
@ -450,20 +422,6 @@ async fn within<T>(
|
||||||
.map_err(|_| anyhow::anyhow!("hive-priv did not answer within {}s", limit.as_secs()))?
|
.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<u64>) -> 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
|
/// 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
|
/// 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.
|
/// 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 `<id-with-slash> <rfer> <excl> …`).
|
|
||||||
///
|
|
||||||
/// `-f <path>` 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/<subvolid>`) — 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/<id>`, …) 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::<u64>(), cols[2].parse::<u64>())
|
|
||||||
{
|
|
||||||
return Ok((rfer, excl));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bail!("no level-0 qgroup data row in `btrfs qgroup show` output: {out:?}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(resp: PrivResponse) -> Result<(String, String)> {
|
fn check(resp: PrivResponse) -> Result<(String, String)> {
|
||||||
if resp.ok {
|
if resp.ok {
|
||||||
Ok((resp.stdout, resp.stderr))
|
Ok((resp.stdout, resp.stderr))
|
||||||
|
|
@ -597,8 +532,8 @@ mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BindMount, CredentialMount, HiveFacts, NetworkIsolation, NixBuilds, PrivRequest,
|
BindMount, CredentialMount, HiveFacts, NetworkIsolation, NixBuilds, PrivRequest,
|
||||||
PrivResponse, PrivStream, call_at, call_streaming_at, check, create_container_request,
|
PrivResponse, PrivStream, call_at, call_streaming_at, check, create_container_request,
|
||||||
older_hive_priv_refused, parse_qgroup_usage, resource_limits_request,
|
older_hive_priv_refused, resource_limits_request, send_snapshot_request,
|
||||||
send_snapshot_request, update_container_request, within, write_nspawn_flags_request,
|
update_container_request, within, write_nspawn_flags_request,
|
||||||
};
|
};
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -748,23 +683,6 @@ mod tests {
|
||||||
assert_eq!(incremental["snapshot_name"], "snap-2");
|
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]
|
#[test]
|
||||||
fn a_refused_response_surfaces_the_error_text() {
|
fn a_refused_response_surfaces_the_error_text() {
|
||||||
let refused = PrivResponse {
|
let refused = PrivResponse {
|
||||||
|
|
|
||||||
|
|
@ -93,21 +93,12 @@ async fn handle_with(
|
||||||
continue;
|
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;
|
let resp = dispatch(&req, coord.clone()).await;
|
||||||
write_response(&mut write, &resp).await?;
|
write_response(&mut write, &resp).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize one `HostResponse` as a JSON line and flush it. Shared by the
|
/// Serialize one `HostResponse` as a JSON line and flush it.
|
||||||
/// ordinary one-response-per-request path and `stream_agent_status`'s
|
|
||||||
/// multi-response one, so the two can't quietly drift on framing.
|
|
||||||
async fn write_response(
|
async fn write_response(
|
||||||
write: &mut tokio::net::unix::OwnedWriteHalf,
|
write: &mut tokio::net::unix::OwnedWriteHalf,
|
||||||
resp: &HostResponse,
|
resp: &HostResponse,
|
||||||
|
|
@ -167,25 +158,11 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
// see the request's doc comment for why the client asks.
|
// see the request's doc comment for why the client asks.
|
||||||
HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?),
|
HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?),
|
||||||
HostRequest::AgentStatus => handle_agent_status().await,
|
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
|
// The hive domain + per-surface public URLs are injected into
|
||||||
// c0re's service env by the hyperhive module; surface them so the
|
// c0re's service env by the hyperhive module; surface them so the
|
||||||
// operator CLI can fill in this hive's own identity (the
|
// operator CLI can fill in this hive's own identity (the
|
||||||
// federation peer-config block).
|
// federation peer-config block).
|
||||||
HostRequest::Urls => HostResponse::urls(hive_urls()),
|
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 } => {
|
HostRequest::UpgradeSubvolume { name } => {
|
||||||
handle_upgrade_subvolume(name.as_str()).await?
|
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<Coordinator>,
|
|
||||||
) -> 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
|
/// 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.
|
/// managed agent rather than a bare (operator/human) account.
|
||||||
///
|
///
|
||||||
|
|
@ -320,67 +254,6 @@ fn agent_exists(name: &hive_types::Ident) -> Result<bool> {
|
||||||
.with_context(|| format!("check agent state dir for {name}"))
|
.with_context(|| format!("check agent state dir for {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_quota_enable() -> Result<HostResponse> {
|
|
||||||
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<u64>) -> Result<HostResponse> {
|
|
||||||
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<HostResponse> {
|
|
||||||
let agents: Vec<String> = 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<HostResponse> {
|
async fn handle_upgrade_subvolume(name: &str) -> Result<HostResponse> {
|
||||||
crate::priv_client::upgrade_agent_subvolume(name)
|
crate::priv_client::upgrade_agent_subvolume(name)
|
||||||
.await
|
.await
|
||||||
|
|
@ -748,12 +621,34 @@ mod tests {
|
||||||
/// `parse error` by the connection loop) while a served verb parses.
|
/// `parse error` by the connection loop) while a served verb parses.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_removed_verb_is_a_parse_error_and_a_served_one_parses() {
|
fn a_removed_verb_is_a_parse_error_and_a_served_one_parses() {
|
||||||
let err =
|
for gone in [
|
||||||
serde_json::from_slice::<HostRequest>(br#"{"cmd":"list"}"#).expect_err("list is gone");
|
"list",
|
||||||
assert!(err.to_string().contains("unknown variant `list`"), "{err}");
|
"subscribe_agent_status",
|
||||||
assert!(
|
"quota_enable",
|
||||||
serde_json::from_slice::<HostRequest>(br#"{"cmd":"subscribe_agent_status"}"#).is_ok()
|
"quota_limit",
|
||||||
);
|
"quota_show",
|
||||||
|
] {
|
||||||
|
let line = format!(r#"{{"cmd":"{gone}"}}"#);
|
||||||
|
let err = serde_json::from_slice::<HostRequest>(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::<HostRequest>(served).is_ok(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(served)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A real `Coordinator` over a throwaway sqlite dir; `handle_with` takes
|
/// A real `Coordinator` over a throwaway sqlite dir; `handle_with` takes
|
||||||
|
|
|
||||||
|
|
@ -155,22 +155,6 @@ pub enum HostRequest {
|
||||||
/// pending reminders) — the `hivectl list-agents` roster view.
|
/// pending reminders) — the `hivectl list-agents` roster view.
|
||||||
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
||||||
AgentStatus,
|
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
|
/// Report this hive's canonical DNS domain
|
||||||
/// (`services.hyperhive.domain`) plus the browser-facing home /
|
/// (`services.hyperhive.domain`) plus the browser-facing home /
|
||||||
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
|
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
|
||||||
|
|
@ -210,32 +194,6 @@ pub enum HostRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
scope: LifecycleScope,
|
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
|
|
||||||
/// <name> 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<u64>,
|
|
||||||
},
|
|
||||||
/// Report btrfs qgroup usage (`hivectl agent <name> 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<Ident>,
|
|
||||||
},
|
|
||||||
/// Migrate an agent's plain state dir to a btrfs subvolume via the
|
/// Migrate an agent's plain state dir to a btrfs subvolume via the
|
||||||
/// privileged helper (`hivectl agent <name> subvol upgrade`). The agent
|
/// privileged helper (`hivectl agent <name> subvol upgrade`). The agent
|
||||||
/// MUST already be stopped — the client orchestrates stop → this →
|
/// 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<u64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub exclusive: Option<u64>,
|
|
||||||
/// 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects which container classes a hive-wide [`HostRequest::Stop`] /
|
/// Selects which container classes a hive-wide [`HostRequest::Stop`] /
|
||||||
/// [`HostRequest::Start`] touches. An all-false scope means **everything**
|
/// [`HostRequest::Start`] touches. An all-false scope means **everything**
|
||||||
/// (the bare `hivectl stop` / `start`); set individual fields to restrict
|
/// (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.
|
/// Empty for requests that produce no such output.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub messages: Vec<String>,
|
pub messages: Vec<String>,
|
||||||
/// `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<Vec<QuotaRow>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand-written rather than `#[derive(Default)]` for one reason:
|
/// Hand-written rather than `#[derive(Default)]` for one reason:
|
||||||
|
|
@ -426,7 +361,6 @@ impl Default for HostResponse {
|
||||||
queued_dags: None,
|
queued_dags: None,
|
||||||
nodes: None,
|
nodes: None,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
quota: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -520,14 +454,4 @@ impl HostResponse {
|
||||||
..Self::default()
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `QuotaShow` result — one usage row per agent.
|
|
||||||
#[must_use]
|
|
||||||
pub fn quota(rows: Vec<QuotaRow>) -> Self {
|
|
||||||
Self {
|
|
||||||
ok: true,
|
|
||||||
quota: Some(rows),
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `hivectl agent <name>` — everything scoped to ONE managed agent:
|
//! `hivectl agent <name>` — everything scoped to ONE managed agent:
|
||||||
//! container lifecycle over the host admin socket (restart/pause/resume/
|
//! container lifecycle over the host admin socket (restart/pause/resume/
|
||||||
//! spawn/kill/destroy/rebuild/choom/watch), plus the
|
//! 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
|
//! `agents_list` (`hivectl list-agents`) is the one genuinely hive-wide
|
||||||
//! read that lives in this module too since it shares the same daemon
|
//! 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
|
//! 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 anyhow::{Context as _, Result, bail};
|
||||||
use hive_host_sock::HostRequest;
|
use hive_host_sock::HostRequest;
|
||||||
|
|
||||||
use crate::cli::{AgentCmd, AgentQuotaCmd};
|
use crate::cli::AgentCmd;
|
||||||
use crate::dag_progress::wait_for_nodes;
|
use crate::dag_progress::wait_for_nodes;
|
||||||
use crate::util::render;
|
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
|
crate::choom::choom(socket, name, resume_session.as_deref()).await
|
||||||
}
|
}
|
||||||
AgentCmd::Watch => crate::watch::watch(socket, name).await,
|
AgentCmd::Watch => crate::watch::watch(socket, name).await,
|
||||||
// `quota` and `subvol` keep their own modules — these arms are
|
// `subvol` keeps its own module — this arm is just the reparenting
|
||||||
// just the reparenting glue that hoists `name` in from the
|
// glue that hoists `name` in from the parent `agent <name>` command.
|
||||||
// parent `agent <name>` 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,
|
|
||||||
},
|
|
||||||
AgentCmd::Subvol { cmd } => crate::subvol::dispatch_subvol(socket, name, cmd).await,
|
AgentCmd::Subvol { cmd } => crate::subvol::dispatch_subvol(socket, name, cmd).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ pub enum Cmd {
|
||||||
/// Everything here targets a single named agent (`hivectl agent foo
|
/// Everything here targets a single named agent (`hivectl agent foo
|
||||||
/// restart`, `hivectl agent foo choom`, …) — anything that acts
|
/// restart`, `hivectl agent foo choom`, …) — anything that acts
|
||||||
/// hive-wide lives at the top level instead (`list-agents`,
|
/// 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 {
|
||||||
/// Agent name (for example `agent1`).
|
/// Agent name (for example `agent1`).
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -51,12 +51,6 @@ pub enum Cmd {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
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 <name>`. Idempotent — safe to re-run. Once
|
|
||||||
/// enabled, `hivectl agent <name> quota show`/`quota set` work.
|
|
||||||
QuotaEnable,
|
|
||||||
/// WireGuard inter-hive mesh setup helpers.
|
/// WireGuard inter-hive mesh setup helpers.
|
||||||
///
|
///
|
||||||
/// Generate this hive's mesh key and print the nix to enable the mesh,
|
/// 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.
|
/// ends the watch with exit 0. `Ctrl-C` to stop.
|
||||||
/// Requires the agent to be running.
|
/// Requires the agent to be running.
|
||||||
Watch,
|
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.
|
/// btrfs subvolume management for this agent's state dir.
|
||||||
///
|
///
|
||||||
/// Upgrade an existing plain-dir agent's state into a btrfs subvolume
|
/// 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 {
|
Subvol {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: SubvolCmd,
|
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)]
|
#[derive(Subcommand)]
|
||||||
pub enum SubvolCmd {
|
pub enum SubvolCmd {
|
||||||
/// Convert a plain-dir agent state root into a btrfs subvolume in
|
/// 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`.
|
/// Bounces the agent to migrate its state, so it requires `--yes`.
|
||||||
Upgrade {
|
Upgrade {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ mod client;
|
||||||
mod dag_progress;
|
mod dag_progress;
|
||||||
use cli::{Cli, Cmd, WgCmd};
|
use cli::{Cli, Cmd, WgCmd};
|
||||||
mod completions;
|
mod completions;
|
||||||
mod quota;
|
|
||||||
mod util;
|
mod util;
|
||||||
use completions::generate_completions;
|
use completions::generate_completions;
|
||||||
mod wg;
|
mod wg;
|
||||||
|
|
@ -54,7 +53,6 @@ async fn main() -> Result<()> {
|
||||||
match cli.cmd {
|
match cli.cmd {
|
||||||
Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await,
|
Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await,
|
||||||
Cmd::ListAgents { json } => agents_list(&socket, json).await,
|
Cmd::ListAgents { json } => agents_list(&socket, json).await,
|
||||||
Cmd::QuotaEnable => quota::quota_enable(&socket).await,
|
|
||||||
Cmd::Wg { cmd } => match cmd {
|
Cmd::Wg { cmd } => match cmd {
|
||||||
WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await,
|
WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await,
|
||||||
WgCmd::Peer {
|
WgCmd::Peer {
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
//! `hivectl quota-enable` + `hivectl agent <name> 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 <name> 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 <name> <size>` — 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<Option<u64>> {
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,7 @@ pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
|
||||||
|
|
||||||
/// Send a provisioning request to the daemon and print its result lines.
|
/// Send a provisioning request to the daemon and print its result lines.
|
||||||
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
/// 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(
|
pub(crate) async fn daemon_request(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
req: hive_host_sock::HostRequest,
|
req: hive_host_sock::HostRequest,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue