hyperhive/swarm-logs/src/main.rs
atlas a39399f037 swarm-logs: an agent's CLI for the swarm log store
An agent can reach VictoriaLogs only through the gateway, and since the
machine query route landed the way to read it has been to hand-roll a
client_credentials token request and a curl, per query. This is the CLI
that closes that: `swarm-logs query '<LogsQL>'`, matched log lines on
stdout, so the answer pipes into grep like any other command's.

Built to the plan posted on the tracker thread: own crate, own
docs/tools reference generated off the clap tree, `query` as the one
verb, and the JSON error body surfaced on a non-200 rather than
swallowed. No `tail`: streaming is a different endpoint with a different
response shape, and folding it in here would be a fatter scope than the
ask.

Minting the token is NOT implemented here — swarm-queue-client already
owns the client_credentials request, its error type and its CA handling,
and a token-endpoint fix has to be findable in one place. What this crate
adds is the agent-shaped half: the client id arrives as a *file* beside
the secret, so nothing outside nix/agent-modules/queue.nix spells
`hive-<name>-agent` twice. That is the same problem hive-agent's
swarm_queue module solves, and swarm-logs/src/auth.rs is its `decide`
restated over this binary's inputs.

⚠️ The plan named one thing to verify empirically before calling the auth
settled: whether authelia's bearer policy for the logs vhost accepts the
agent client's audience. Measured from inside a container: it does not.
The client minted a token fine but with `aud: []` and `scp: []`, asking
for the logs URL as an audience answered `invalid_target`, and presenting
the audience-less token to the gateway answered a bare 401. So
swarm-authelia.nix's agentClients gains `authelia.bearer.authz` and the
query URL as a second audience — authelia authorises a bearer token by
the URL being requested, and that URL is now one binding read by three
places rather than three spellings of one address.

The URL reaches an agent the same way its queue coordinates do: computed
on the host (a container cannot derive a gateway address), forwarded by
hive_c0re::meta into the container's option set, and consumed by a new
agent module that installs the binary *wrapped* with its coordinates —
the shape swarm-controller.nix installs swarmctl in. Gated on the queue
credential as well as on the URL: a binary that can only answer 401 is
worse than no binary, because an agent reads a 401 as "no logs", which is
the exact confusion the store's machine route was added to end.
2026-09-17 01:02:14 +02:00

123 lines
4.5 KiB
Rust

//! `swarm-logs` — an agent's CLI for the swarm log store.
//!
//! An agent can reach VictoriaLogs only through the gateway, and until this
//! existed the way to read it was to hand-roll a `client_credentials` token
//! request and a curl by hand, per query. That is the gap this closes: one
//! verb, a LogsQL string in, matched log lines out, so the answer pipes into
//! `grep` like any other command's.
//!
//! **Read-only and query-only.** It posts to the store's LogsQL query
//! endpoint and nothing else — there is no ingest path here, and no `tail`.
//! Streaming is a different endpoint with a different response shape and is
//! deliberately left for a follow-up rather than folded in.
//!
//! ⚠️ **The route applies no scoping.** `swarm-victorialogs.nix` forwards the
//! query unmodified, so any authenticated caller reads the whole swarm's
//! logs — every hive's, not just its own. That is the rule in force rather
//! than an omission on this binary's part; when read permissions exist they
//! attach at that location, not here.
//!
//! Distinct from `hive-metric`, which is the other half of the same
//! observability surface from an agent's seat: that one *writes* a custom
//! metric, this one *reads* logs.
mod auth;
mod query;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "swarm-logs",
version,
about = "query the swarm's log store from an agent"
)]
struct Cli {
#[command(subcommand)]
command: Verb,
}
#[derive(Subcommand)]
enum Verb {
/// Run a LogsQL query and print the matched log lines.
Query {
/// The LogsQL query. A bare word is a full-text search for it.
///
/// Quote it: LogsQL uses characters the shell also claims, and an
/// unquoted `|` pipes this command into the rest of your query
/// instead of sending it.
logsql: String,
/// Stop after this many records.
///
/// Unset means the store's own default. Worth setting on a broad
/// query — nothing between here and the store narrows one, so a bare
/// word matches across every hive in the swarm.
#[arg(long)]
limit: Option<u64>,
/// What to print for each matched record.
#[arg(long, value_enum, default_value = "message")]
format: query::Format,
},
/// Print this CLI's reference as markdown (source of `docs/tools/swarm-logs-cli.md`).
///
/// No `completions` sibling, unlike `hivectl` and `swarmctl`: those are
/// installed on an operator's interactive host, where a completion script
/// has a shell to be sourced into. This one is run by an agent through a
/// tool call, which has no line editor to complete against — the verb
/// would exist only to be listed.
#[command(hide = true)]
MarkdownDocs,
}
fn main() -> Result<()> {
match Cli::parse().command {
Verb::Query {
logsql,
limit,
format,
} => {
// Config resolved inside this arm and not up front: the two
// verbs below are pure functions of the command tree and must
// keep working on a machine that has no log store wired up at
// all — `markdown-docs` in particular runs in a nix check, where
// no credential exists and none should.
let cfg = auth::Config::from_env()?;
let stdout = std::io::stdout();
query::run(&cfg, &logsql, limit, format, &mut stdout.lock())
}
Verb::MarkdownDocs => {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory as _;
/// clap's own consistency check over the command tree — catches a
/// duplicate long flag or a malformed `about` at test time rather than on
/// the first run of a verb nobody exercised.
#[test]
fn the_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
/// The default matters: it is what an agent gets when it pipes into
/// `grep` without reading the flags first.
#[test]
fn query_defaults_to_projecting_the_message() {
let cli = Cli::try_parse_from(["swarm-logs", "query", "a-marker"]).expect("parses");
let Verb::Query { format, limit, .. } = cli.command else {
panic!("expected the query verb");
};
assert_eq!(format, query::Format::Message);
assert_eq!(limit, None, "no client-side default limit is imposed");
}
}