crates/gunnar-ui-server/src/api/usage.rs
//! **What one namespace holds**, counted by gunnar.
//!
//! # This is not a bill, and it is not behind anything
//!
//! gunnar serves `Usage` unconditionally. It is not behind a feature, not
//! behind a licence, and not behind an edition — deliberately, because a person
//! running gunnar on their own disk wants the same numbers as anyone else, and
//! the counting happens inside the server either way. Everything on this route
//! is the same for a self-hoster who will never pay anyone as it is for
//! anybody else, and it stays that way: a number is a fact about a disk.
//!
//! # Why `Usage.Read` and never `Usage.ReadAll`
//!
//! gunnar splits the two on the grant they take, the same way `Admin` splits
//! `RepoStorage` from `StorageInfo`:
//!
//! * `Usage.Read` names ONE namespace and takes `Read` **on that namespace** —
//! so an owner can see their own numbers and nothing else.
//! * `Usage.ReadAll` spans every tenant and takes a **server-wide** `Read`.
//!
//! The console asks the narrow question. Reading everything and picking one row
//! out of it would answer the same question by first having every namespace's
//! figures in this process, which is a different thing to have done, and it
//! would make a credential scoped to one namespace fail on a page about that
//! namespace. `namespace_usage_asks_only_about_the_namespace_it_names` holds
//! that: the fake gunnar behind it refuses `ReadAll` exactly as a scoped
//! credential's would.
//!
//! # The refusal is gunnar's, and it is passed through
//!
//! There is no second authorisation rule here. gunnar decides whether the
//! caller may read a namespace's meters, and the console relays its answer
//! verbatim — a `PermissionDenied` becomes a 403 carrying gunnar's own words
//! and **no meters at all**. A rule invented in the console that `gunnar
//! control usage` would not share is the definition of a gate that does not
//! hold; the console's job here is not to have an opinion, it is not to leak
//! one namespace's numbers into another namespace's answer.
//!
//! **Measured, not assumed**: the credential this console signs with today
//! holds a server-wide grant, so on `gunnar.rs` gunnar answers every namespace
//! and the refusal path is not exercised in production. What the tests below
//! prove is that when gunnar DOES refuse — which is what it does for any
//! credential scoped to fewer namespaces than the page asks about — the console
//! refuses too, rather than falling back to a wider call or showing zeroes.
//!
//! # The allowance: what the namespace is allowed, beside what it holds
//!
//! Since 2026-09-05 `Usage.Read` also answers with the namespace's
//! **allowance** — per meter, the cap the box holds for it, ABSENT when there
//! is none. On the community appliance every cap is absent for everyone; on
//! the metered one a cap is there when a signed fact names the meter. The
//! console carries each cap as it came (`null` = no cap, never 0) and derives
//! ONE thing from it, here and nowhere else: `remaining`, per meter —
//!
//! * with a cap: `cap − used`, and for a byte meter no more than the box can
//! serve (`Admin.Capacity.servable_bytes`, when the console may read it);
//! * without a cap: for a byte meter, what the box can serve — or nothing at
//! all when the box cannot be read; for a count (`repos`, `objects`),
//! nothing, because nothing bounds it that the box can say.
//!
//! No price, no plan, no date, no link to anywhere: a cap is a resource
//! number, and this route is exactly as useful to a person who will never pay
//! anyone as it is to anybody else.
//!
//! # A cap is not automatically a wall, and the answer says which
//!
//! Since gunnar grew `Allowance.enforcement` the same read carries, per meter,
//! **what a cap on it DOES on that box**: `enforced` — the product refuses when
//! over — or `measured` — counted and reported; nothing refuses. Those are
//! monetize's own two sentences (`monetize_product::Enforcement::describe`) and
//! they are used verbatim on the page, because gunnar took monetize's
//! vocabulary onto the wire on purpose and a third spelling invented here would
//! be the one that goes stale.
//!
//! It matters because three of the seven meters are a door and four are a
//! reading, and until this field existed all seven arrived in one shape under
//! one message called `Allowance`: a fact carrying `caps["repos"]=5` showed a
//! tenant a limit of 5 that nothing on the box refuses the sixth of.
//!
//! **An EMPTY map is "this box did not say", never "nothing is enforced."** See
//! [`Usage::enforcement`]: an older gunnar sends no map and still cuts a push
//! mid-stream.
//!
//! # The box
//!
//! `Admin.Capacity`, asked with `requested_bytes: 0` and no namespace — the
//! console wants the measurement (total, free, servable, whether the set can
//! grow), not a verdict. It takes a **server-wide** `Read`. When gunnar refuses
//! it, or has no disk gate to measure with, the answer here carries no box and
//! says why in gunnar's own words; the meters and the caps are unaffected, and
//! `remaining` is computed without the box term. A console that can read one
//! namespace and not the box still draws a correct page.
//!
//! The same answer carries the box's OTHER two resources — cores and RAM —
//! and they are carried here too. They are NOT namespace meters: `UI.md`'s
//! *"Scope is the PRODUCT's call"* says gunnar meters disk per tenant and
//! CPU/RAM per product, a tenant does not own cores, and `Allowance` has no
//! field for either. So they never enter the Room table and never enter
//! `remaining`; they are the box's own three-number line, beside disk. Each
//! is `Option` and **absent means gunnar did not measure it, never 0** —
//! `machine_reason` says which, in gunnar's words, and no sentence about it is
//! written here.
//!
//! # FLOW: what moved, beside what is held
//!
//! Every meter above is a **stock** meter. It answers *how much is on the disk
//! right now*, and it reads exactly the same for a namespace that was pushed to
//! this morning and one whose last transfer was in March. That is the defect
//! the flow half closes: nothing on this page could tell a tenant who is using
//! the product from one who has gone silent, and nothing could tell either of
//! them from a tenant who is being turned away at a wall.
//!
//! It arrives in two shapes, and the console carries both:
//!
//! * **The glance** — seven fields on `NamespaceUsage` itself
//! (`bytes_in_today`, `bytes_out_today`, `pushes_today`, `fetches_today`,
//! `refused_today`, `last_push_unix_ms`, `last_fetch_unix_ms`), riding on the
//! same `Usage.Read` the meters do, so a reader gets them without a second
//! call. "Today" is the current UTC day and the counters reset at that
//! boundary; they are carried here and NOTHING is derived from them.
//! * **The series** — [`activity`], one row per UTC day that moved something.
//!
//! The two timestamps are `Option<i64>` on this console's JSON where gunnar
//! sends `int64`, and that is not decoration: proto3 cannot tell an unset
//! `int64` from a zero, gunnar sends 0 for *never pushed inside the kept
//! window*, and a 0 rendered as a date puts **1 Jan 1970** — or, relatively,
//! "689 months ago" — beside a namespace that has simply never been pushed to.
//! [`crate::api::recorded`] is the one place that rule is written, and it is
//! the same rule `measured_at_unix_ms` already used.
//!
//! # `Usage.Activity`: the series, and the SAME grant
//!
//! `Usage.Activity` takes `Read` **on the namespace** — the identical grant
//! `Usage.Read` takes, and deliberately not a server-wide one: what a tenant
//! pushed and fetched is that tenant's own business, exactly as their meters
//! are. So there is no second authorisation rule in the console for it either.
//! gunnar decides, and its `PermissionDenied` becomes a 403 in gunnar's own
//! words carrying **no days at all** —
//! `a_namespaces_activity_is_refused_and_leaks_no_days` drives it.
//!
//! The rows are passed through as they came: **oldest first, and a day with no
//! traffic is ABSENT rather than a row of zeroes.** That absence is the point.
//! Zero-filling the gaps here would be inventing measurements — the console
//! would be asserting that a day gunnar never wrote had no traffic, which is a
//! claim only gunnar's ledger can make — and it would destroy the one
//! distinction the page needs: `retained_days` with an empty `days` means
//! *nothing moved*, and a refusal means *this could not be read*. Those are
//! different sentences on screen.
//!
//! `refused_by_reason` is carried the same way. gunnar sends only the reasons
//! that actually happened (`admission`, `cap`, `policy`); a reason it did not
//! send is **absent, never 0**, because "no push was refused by policy today"
//! and "this box does not break refusals down by reason" are different facts
//! and a fabricated `"policy": 0` would flatten them into one.
//!
//! # What is cached, and what is not
//!
//! The box is: one value for the whole server, one caller (this console's
//! key), so [`crate::grpc::Cached`] holds it for a few seconds like `Stats`.
//! The meter read is NOT: the cache holds a value under a fixed name, and the
//! answer depends on the namespace asked for AND on who asked. One shared slot
//! for a per-namespace, per-caller answer is a way to serve `beta`'s numbers
//! to a request for `alpha`. A visit to this page is one `Usage.Read`.
//!
//! `Usage.Activity` is not cached for the same reason, twice over: it is
//! per-namespace and per-caller, and a stale day row is a graph that is wrong
//! about today.
use std::collections::BTreeMap;
use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;
use crate::api::{ApiError, ApiResult, Reader};
use crate::app::AppState;
use crate::pb;
/// One namespace's meters, as the console shows them.
///
/// The field names are gunnar's own. They are not translated into friendlier
/// words here, because `gunnar control usage` prints those names and an
/// operator comparing the two must not have to guess which of ours is which of
/// its.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Usage {
pub namespace: String,
/// Objects on disk that are **not** re-derivable: the hot tier minus the
/// exploded read cache, plus the cold tier.
pub pack_bytes: u64,
/// The exploded read cache. Re-derivable, and gunnar will re-derive it —
/// which is why it is reported apart from `pack_bytes` rather than inside
/// it.
pub cache_bytes: u64,
/// The cold (znippy) tier alone. `0` on an appliance that keeps one archive
/// per repository, which has no separate cold tier to report — that zero is
/// the truth of what is measured, not a placeholder.
pub cold_bytes: u64,
/// LFS objects. gunnar has no LFS store yet and reports 0 for every
/// namespace on every server today.
pub lfs_bytes: u64,
/// Soft-deleted, not yet purged, and still on the disk. These bytes are in
/// none of the numbers above: a tombstoned repository has no catalog row.
pub tombstoned_bytes: u64,
pub repos: u64,
/// Indexed objects across the namespace's OPEN stores. The RAM those
/// stores cost is roughly 382 B each; a closed store reports none, because
/// nothing is paying for it.
pub objects: u64,
/// When gunnar took the measurement. `None` when it did not record one —
/// proto3 cannot tell "unset" from "zero" and gunnar was not running in
/// 1970.
pub measured_at_unix_ms: Option<i64>,
// ── FLOW: what moved today ────────────────────────────────────────────
//
// Seven fields, carried and never derived. See the module doc: every meter
// above is a STOCK meter and reads the same for a namespace pushed to this
// morning as for one last touched in March. "Today" is the current UTC day
// and gunnar resets the counters at that boundary; the console does not
// know when that was and must not pretend to.
/// Wire bytes gunnar ACCEPTED for this namespace today (receive-pack, and
/// the client half of every other conversation).
pub bytes_in_today: u64,
/// Wire bytes gunnar SERVED today (upload-pack, the ref advertisement, the
/// archive stream).
pub bytes_out_today: u64,
/// receive-pack sessions that finished without an error today.
pub pushes_today: u64,
/// upload-pack sessions today, clones included. A bare advertisement
/// (`git ls-remote`) is not one — counting it would make a monitoring
/// probe look like a customer.
pub fetches_today: u64,
/// Transfers turned away today, whatever the reason. The breakdown is on
/// the day rows ([`Day::refused_by_reason`]); this is the one number a
/// glance needs — *is this tenant hitting walls*.
pub refused_today: u64,
/// The last transfer that put bytes IN — **not restricted to today**, and
/// `None` when the namespace has never been pushed to inside the kept
/// window.
///
/// `Option`, where gunnar sends a bare `int64`, for the reason
/// [`crate::api::recorded`] exists: proto3 cannot tell unset from zero, and
/// a namespace that has never been pushed to would otherwise be dated
/// **1 Jan 1970** on a page whose whole job is to say when it was last
/// seen.
pub last_push_unix_ms: Option<i64>,
/// The last transfer that took bytes OUT. Same rule as
/// [`Usage::last_push_unix_ms`].
pub last_fetch_unix_ms: Option<i64>,
/// Per meter, the cap the box holds for this namespace. `null` = no cap,
/// and it is never 0: gunnar itself reads a 0 cap as unlimited and reports
/// it absent.
pub caps: PerMeter,
/// **Per meter, what a cap on it DOES on the box that answered** —
/// `enforced` (the product refuses when over) or `measured` (counted and
/// reported; nothing refuses). See [`enforcement_word`] for why these are
/// words and not gunnar's wire numbers.
///
/// **EMPTY means the box did not say, and that is not the same as
/// "nothing is enforced."** gunnar has answered this map on every
/// `Usage.Read` since it grew `Allowance.enforcement`, always for all
/// seven meters, capped or not — so an empty map here is a gunnar OLDER
/// than that field, which is a self-hoster's box and not a box with no
/// doors. That box refuses on `pack_bytes`, `cache_bytes` and `lfs_bytes`
/// exactly as this one does; it simply has no way to say so. Rendering it
/// as seven readings would tell a tenant their pack quota is a line item,
/// and the push that is cut mid-stream would be the correction.
pub enforcement: BTreeMap<String, String>,
/// Per meter, what is left: see the module doc for the one rule. `null` =
/// nothing bounds it that this console can name.
pub remaining: PerMeter,
/// The box, when the console may read it.
#[serde(rename = "box")]
pub machine: Option<Machine>,
/// Why there is no box, in gunnar's own words; empty when there is one.
pub box_unreadable: String,
}
/// One optional number per meter, named as gunnar names the meters.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct PerMeter {
pub pack_bytes: Option<u64>,
pub cache_bytes: Option<u64>,
pub cold_bytes: Option<u64>,
pub lfs_bytes: Option<u64>,
pub tombstoned_bytes: Option<u64>,
pub repos: Option<u64>,
pub objects: Option<u64>,
}
/// The box: what `Admin.Capacity` measured, and whether the set can grow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Machine {
pub total_bytes: u64,
pub free_bytes: u64,
/// Free minus the floor gunnar keeps in reserve: what can actually be
/// served.
pub servable_bytes: u64,
/// `sealed` (cannot gain capacity while it runs), `at_restart` (the boot
/// stack grows it at the next boot), `at_runtime` (grows without a
/// restart), `operator_only` (an operator can extend the filesystem out
/// of band), or `unknown`.
pub growth: &'static str,
/// The store root the numbers are of (`/data` on the appliance).
pub root: String,
/// What the root IS, in gunnar's own words: `4×2.0G raid0 xfs`, `xfs`, or
/// empty when gunnar could not tell.
pub data_set: String,
/// The disk floor in force: space that exists and may never be handed to
/// a tenant, so that the box can still compact a pack and rewrite its
/// control documents after it has stopped accepting pushes. Carried
/// because without it `total`, `free` and `servable` do not add up on the
/// page, and a reader who checks the arithmetic and finds it short has
/// been told the console is wrong when it is not.
pub floor_bytes: u64,
// ── the box's OTHER two resources ────────────────────────────────────
//
// `Admin.Capacity` has measured cores and RAM since the machine half
// landed on `CapacityResponse` (fields 14–18). They are `Scope::Product`,
// not per tenant — `UI.md`'s "Scope is the PRODUCT's call": a tenant does
// not own cores, the box does — so they have no cap in `Allowance` and no
// row in the Room table. They belong to the box, and the box is where
// they are carried.
//
// **Every one is `Option` and absent means NOT MEASURED, never 0.** That
// is gunnar's own rule on the wire and it is kept here: a `0` handed to a
// page would draw an empty machine, which is a different claim from "the
// kernel would not say". [`Machine::machine_reason`] carries which.
/// The CPUs gunnar may run on, ×1000 (affinity mask, clamped by a cgroup
/// `cpu.max` quota when one is set).
pub cpu_millicores_total: Option<u64>,
/// `/proc/loadavg`'s 1-minute load ×1000 — the WHOLE box's runnable
/// tasks, not gunnar's own CPU time, and an approximation that can exceed
/// `cpu_millicores_total`. The page says so in those words rather than
/// drawing it as a fraction of a ceiling.
pub cpu_millicores_busy: Option<u64>,
/// `/proc/meminfo` `MemTotal`, clamped to a cgroup `memory.max` limit.
pub ram_bytes_total: Option<u64>,
/// `/proc/meminfo` `MemAvailable`, clamped under a cgroup limit.
pub ram_bytes_available: Option<u64>,
/// **How the box was measured, or why a field above is absent, in
/// gunnar's own words** — the sources it named, any cgroup clamp that
/// applied, and the OS error behind every absence. gunnar never sends it
/// empty. It is carried verbatim and this console writes no sentence of
/// its own about why a number is missing.
pub machine_reason: String,
}
/// `Admin.Capacity`'s growth verdict, as one word the page renders.
pub fn growth_word(growth: i32) -> &'static str {
match pb::SetGrowth::try_from(growth) {
Ok(pb::SetGrowth::GrowthSealed) => "sealed",
Ok(pb::SetGrowth::GrowthAtRestart) => "at_restart",
Ok(pb::SetGrowth::GrowthAtRuntime) => "at_runtime",
Ok(pb::SetGrowth::GrowthOperatorOnly) => "operator_only",
_ => "unknown",
}
}
/// **The data set under the store root**, as `GET /api/dataset` sends it:
/// how big it is, how much is in use and free, how it is laid out, and whether
/// it can grow. Read-only, and the same on every build — a person running
/// gunnar on their own disks wants exactly these numbers.
///
/// Every number is `Admin.Capacity`'s own; nothing is derived except
/// `used_bytes` (`total − free`) and the parse of `layout` into its parts,
/// which never invents a part gunnar's words did not carry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DataSet {
pub root: String,
pub total_bytes: u64,
pub used_bytes: u64,
pub free_bytes: u64,
/// gunnar's layout words verbatim (`4×2.0G raid0 xfs`); empty = unknown.
pub layout: String,
/// `4` of `4×2.0G`, when the set is an array.
pub members: Option<u32>,
/// `2.0G` of `4×2.0G`, as gunnar rounded it.
pub member_size: Option<String>,
/// `raid0`, when the set is an array.
pub level: Option<String>,
/// `xfs`, the filesystem on top.
pub filesystem: Option<String>,
/// [`growth_word`]'s word.
pub growth: &'static str,
}
/// gunnar's `data_set` words → their parts. `4×75G raid0 xfs` is an array;
/// `xfs` alone is a single filesystem; anything else keeps the verbatim words
/// and no parts, rather than a guess.
pub fn layout_parts(words: &str) -> (Option<u32>, Option<String>, Option<String>, Option<String>) {
let parts: Vec<&str> = words.split_whitespace().collect();
match parts.as_slice() {
[fs] => (None, None, None, Some((*fs).to_owned())),
[array, level, fs] => match array.split_once('\u{d7}') {
Some((n, size)) => match n.parse::<u32>() {
Ok(n) => (Some(n), Some(size.to_owned()), Some((*level).to_owned()), Some((*fs).to_owned())),
Err(_) => (None, None, None, None),
},
None => (None, None, None, None),
},
_ => (None, None, None, None),
}
}
/// The box → the data set.
pub fn render_data_set(machine: &Machine) -> DataSet {
let (members, member_size, level, filesystem) = layout_parts(&machine.data_set);
DataSet {
root: machine.root.clone(),
total_bytes: machine.total_bytes,
used_bytes: machine.total_bytes.saturating_sub(machine.free_bytes),
free_bytes: machine.free_bytes,
layout: machine.data_set.clone(),
members,
member_size,
level,
filesystem,
growth: machine.growth,
}
}
/// `GET /api/dataset` — the data set, from one `Admin.Capacity` asked for
/// nothing in particular. Not cached: gunnar's refusal must reach the browser
/// as itself (a 403 with gunnar's words), which the box cache flattens to a
/// sentence for the usage page's sake.
/// `GET /api/machine/allocation` — **the box's own allocation**: disk, cores
/// and memory as `Admin.Capacity` measured them, for Administration →
/// Machine. An admin read (`role_floor`'s `/api/machine` prefix): it is the
/// whole box, not anybody's namespace. Not cached, for the reason
/// [`dataset`] gives.
pub async fn allocation(
reader: crate::api::Reader) -> ApiResult<Machine> {
let response = reader
.gunnar
.admin()
.capacity(pb::CapacityRequest {
requested_bytes: 0,
namespace: String::new(),
disks: false,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render_box(response)))
}
pub async fn dataset(
reader: crate::api::Reader) -> ApiResult<DataSet> {
let response = reader
.gunnar
.admin()
.capacity(pb::CapacityRequest {
requested_bytes: 0,
namespace: String::new(),
disks: false,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render_data_set(&render_box(response))))
}
/// **What a cap on one meter does, as a WORD** — `enforced`, `measured`,
/// `provisioned` — or `None` for a code this console has no word for.
///
/// # Why a word and not the number gunnar sent
///
/// `Allowance.enforcement` is `map<string, Enforcement>`, and prost hands that
/// over as a `HashMap<String, i32>`. Putting the `i32` on this console's JSON
/// would publish an enum's ORDINAL as an API value: `2` means `ENFORCED` only
/// for as long as nobody renumbers the proto, the browser would carry a magic
/// number nobody can read in a network tab, and the day a variant is inserted
/// the page would silently relabel every wall as a reading. The word is the
/// thing gunnar means; the number is an encoding of it.
///
/// The spelling is the proto variant's own name, lowercased and without its
/// `ENFORCEMENT_` prefix, which is this console's existing idiom for exactly
/// this ([`growth_word`] does the same to `SetGrowth`) and is monetize's too
/// (`Scope::name`, `Growth::name`). One vocabulary, three repositories.
///
/// `None` for `ENFORCEMENT_UNSPECIFIED` and for any future variant: a word this
/// build cannot name is no word at all, and the meter is left OUT of the map
/// rather than given a placeholder. "This box did not say" is the honest
/// reading of both, and it is the one the page already has to handle for an
/// older gunnar.
pub fn enforcement_word(code: i32) -> Option<&'static str> {
match pb::Enforcement::try_from(code) {
Ok(pb::Enforcement::Measured) => Some("measured"),
Ok(pb::Enforcement::Enforced) => Some("enforced"),
Ok(pb::Enforcement::Provisioned) => Some("provisioned"),
Ok(pb::Enforcement::Unspecified) | Err(_) => None,
}
}
/// gunnar's capacity answer → the box.
pub fn render_box(response: pb::CapacityResponse) -> Machine {
Machine {
total_bytes: response.total_bytes,
free_bytes: response.free_bytes,
servable_bytes: response.servable_bytes,
growth: growth_word(response.growth),
root: response.root,
data_set: response.data_set,
floor_bytes: response.floor_bytes,
// Carried, not derived and not defaulted: `prost` decodes an absent
// `optional` field as `None`, and `None` reaches the page as `null`.
cpu_millicores_total: response.cpu_millicores_total,
cpu_millicores_busy: response.cpu_millicores_busy,
ram_bytes_total: response.ram_bytes_total,
ram_bytes_available: response.ram_bytes_available,
machine_reason: response.machine_reason,
}
}
/// gunnar's message → the console's shape.
///
/// Every field is carried and none is derived. In particular `pack_bytes` is
/// **not** recomputed here from the other three: gunnar's own walk is what
/// `gunnar control usage` prints and what any meter is read from, and a second
/// arithmetic in the console would be a second answer to the same question.
///
/// The allowance is carried the same way: a cap gunnar sent is a cap, a cap
/// it did not send is `None`, and no cap is invented from a plan, a default or
/// the box. **`remaining` is gunnar's too** (thin layer, owner 2026-10-04):
/// `ReadUsageResponse.remaining`, cap − held per meter, absent where nothing
/// caps it. The console used to compute it four different ways (here, clamped
/// to the box; the account pane; "My room"; the import gate); now there is
/// one answer and gunnar gives it. The box is carried beside it, never folded
/// into it.
pub fn render(
usage: pb::NamespaceUsage,
allowance: pb::Allowance,
remaining: Option<pb::Remaining>,
machine: Result<Machine, String>,
) -> Usage {
let caps = PerMeter {
pack_bytes: allowance.pack_bytes,
cache_bytes: allowance.cache_bytes,
cold_bytes: allowance.cold_bytes,
lfs_bytes: allowance.lfs_bytes,
tombstoned_bytes: allowance.tombstoned_bytes,
repos: allowance.repos,
objects: allowance.objects,
};
let remaining = remaining.unwrap_or_default();
let left = PerMeter {
pack_bytes: remaining.pack_bytes,
cache_bytes: remaining.cache_bytes,
cold_bytes: remaining.cold_bytes,
lfs_bytes: remaining.lfs_bytes,
tombstoned_bytes: remaining.tombstoned_bytes,
repos: remaining.repos,
objects: remaining.objects,
};
// Sorted, and by the meter's own name: prost's map arrives in a
// `HashMap`'s order, which is a different order on every process start,
// and a JSON body that reshuffles between two reads of the same
// unchanged namespace is a diff nobody can review.
let enforcement: BTreeMap<String, String> = allowance
.enforcement
.iter()
.filter_map(|(meter, code)| {
enforcement_word(*code).map(|word| (meter.clone(), word.to_owned()))
})
.collect();
let (machine, box_unreadable) = match machine {
Ok(b) => (Some(b), String::new()),
Err(why) => (None, why),
};
Usage {
namespace: usage.namespace,
pack_bytes: usage.pack_bytes,
cache_bytes: usage.cache_bytes,
cold_bytes: usage.cold_bytes,
lfs_bytes: usage.lfs_bytes,
tombstoned_bytes: usage.tombstoned_bytes,
repos: usage.repos,
objects: usage.objects,
measured_at_unix_ms: crate::api::recorded(usage.measured_at_unix_ms),
// The flow half, carried the same way the stock half is: gunnar's
// number or nothing. In particular `bytes_in_today` is NOT the sum of
// the day rows' `bytes_in` for today, and `refused_today` is NOT the
// sum of `refused_by_reason` — gunnar counts each of them once, at the
// transport, and a second arithmetic here would be a second answer to
// the same question that drifts the first time a reason is added.
bytes_in_today: usage.bytes_in_today,
bytes_out_today: usage.bytes_out_today,
pushes_today: usage.pushes_today,
fetches_today: usage.fetches_today,
refused_today: usage.refused_today,
last_push_unix_ms: crate::api::recorded(usage.last_push_unix_ms),
last_fetch_unix_ms: crate::api::recorded(usage.last_fetch_unix_ms),
caps,
enforcement,
remaining: left,
machine,
box_unreadable,
}
}
/// The box, or gunnar's reason there is none. Cached for a few seconds — for
/// the sessions that sign with the console's own key only.
///
/// A refusal is not an error for this page: a key that may read one namespace
/// and not the server still gets that namespace's meters and caps, with the
/// box term left out of `remaining` and the reason on the wire.
///
/// **Asked as the reader** (thin layer, owner 2026-10-04). Until this line the
/// box — total, free, servable, floor, data set, cores and memory — was read
/// with the console's server-wide key and handed to ANY namespace reader:
/// every customer saw the appliance's capacity, which `/api/dataset` keeps
/// behind the operator floor and `api::subscription` says is "not a tenant's
/// to read". gunnar answers `Admin.Capacity` to a server-wide Read grant, so a
/// customer's own key is refused, `machine` is absent and the reason says so.
async fn read_box(reader: &Reader, state: &AppState) -> Result<Machine, String> {
state
.caches
.capacity
.get_for(reader.may_cache(), async {
reader
.gunnar
.admin()
.capacity(pb::CapacityRequest {
requested_bytes: 0,
namespace: String::new(),
disks: false,
})
.await
.map(|response| render_box(response.into_inner()))
.map_err(|status| {
format!(
"Gunnar did not answer Admin.Capacity for this reader's key: {}",
status.message()
)
})
})
.await
}
/// `GET /api/namespaces/{name}/usage`
///
/// One namespace, one `Usage.Read`. gunnar answers `NotFound` for a namespace
/// that is not in its catalog and `PermissionDenied` when the caller may not
/// read it, and both reach the browser as themselves.
pub async fn read(
reader: Reader,
State(state): State<AppState>,
Path(namespace): Path<String>,
) -> ApiResult<Usage> {
// **The CALLER's credential, not this console's.**
//
// The doc above says gunnar answers `PermissionDenied` when the caller may
// not read this namespace, and until 2026-09-17 it could not: the call was
// signed with `state.gunnar`, the console's server-wide admin key, and
// gunnar cannot refuse a caller it cannot narrow. `role_floor` puts a GET
// at `Viewer` and the namespace comes from the path, so a signed-in viewer
// read any namespace's meters, caps and activity by spelling the name —
// how much every other customer stores and what they pay for.
//
// `Reader` is what the other nine read modules in this crate already take,
// and it changes nothing for an operator: it hands an operator or admin
// session the console's own key, which is what they had. A viewer is
// answered as themselves and gunnar decides. Pinned by
// `a_viewers_read_is_not_signed_with_the_consoles_admin_key`.
let response = reader
.gunnar
.usage()
.read(pb::ReadUsageRequest {
namespace: namespace.clone(),
})
.await
.map_err(ApiError::from)?
.into_inner();
// gunnar cannot send this empty for a namespace it accepted — `Read` either
// fills the field or answers NotFound — but proto3 makes the message
// optional on the wire, and a `unwrap_or_default()` here would render an
// entire namespace as seven zeroes, which is exactly the answer a person
// reading this page must never be given by mistake.
let usage = response.usage.ok_or_else(|| {
ApiError(crate::grpc::Fail {
code: "internal".to_owned(),
message: format!(
"Gunnar accepted the read for namespace {namespace:?} but sent no meters with \
it, so there is nothing to show. Showing zeroes here would read as an empty \
namespace."
),
})
})?;
// A gunnar from before the allowance sends none; that is every cap absent,
// which is also what it would have said.
//
// **The default is NOT the same answer for `enforcement`, and that
// asymmetry is deliberate.** An absent cap and an absent allowance mean the
// same thing — nothing caps this meter — so defaulting is lossless there.
// An absent enforcement map means *"this box did not say"*, and the one
// reading it must never be given is *"nothing on this box is enforced"*:
// the appliance being re-imaged tonight answers the map, but a
// self-hoster's older gunnar does not, and that box still cuts a push
// mid-stream at `pack_bytes`, still bounds the explode cache at
// `cache_bytes`, and still refuses an LFS upload at `lfs_bytes`. So the
// empty map is carried out to the browser AS empty, and
// `pages::usage::wall_or_reading` renders it as the silence it is rather
// than as seven readings. See `Usage::enforcement`.
let allowance = response.allowance.unwrap_or_default();
let machine = read_box(&reader, &state).await;
Ok(Json(render(usage, allowance, response.remaining, machine)))
}
// ── The series ─────────────────────────────────────────────────────────────
/// **How many UTC days the Activity panel asks for.**
///
/// Thirty, and the number is here rather than in the handler because the page
/// and the empty state both talk about it in words — *"no activity in the last
/// 30 days"* — and a window that said 30 and asked for 14 would put a true
/// sentence over a false graph.
///
/// gunnar keeps 400 (`ActivityResponse::retained_days`), so this is a slice of
/// what it holds and never the whole of it. A console that asked for the lot
/// would pull four hundred rows across the wire to draw thirty bars.
pub const ACTIVITY_DAYS: i64 = 30;
const DAY_MS: i64 = 86_400_000;
/// **The window's first millisecond**: midnight UTC, `days − 1` days before the
/// UTC day `now_unix_ms` falls in.
///
/// Floored to a day boundary rather than measured back from the current clock,
/// because gunnar's rows ARE whole UTC days: asking for "the last 30 × 24
/// hours" at 14:00 would land mid-day and make the oldest bar a partial day
/// drawn at full height beside twenty-nine whole ones. `days − 1` because the
/// window is inclusive at both ends — thirty days ending today is today plus
/// the twenty-nine before it, and an off-by-one here is a thirty-first bar.
///
/// `div_euclid`, not `/`: a pre-1970 timestamp is not a real input, but integer
/// division rounds towards zero and would floor a negative millisecond to the
/// day AFTER the one it is in. It costs nothing and it is the kind of thing
/// that is wrong for six months before anybody notices.
pub fn window_start(now_unix_ms: i64, days: i64) -> i64 {
let midnight = now_unix_ms.div_euclid(DAY_MS) * DAY_MS;
midnight - (days - 1) * DAY_MS
}
/// One UTC day's flow, exactly as gunnar counted it.
///
/// A day with no traffic HAS NO ROW — see [`activity`]. Nothing in this shape
/// is derived: `refused` is gunnar's own total and not the sum of
/// [`Day::refused_by_reason`], because a reason gunnar has not learned to name
/// yet would silently make the two disagree.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Day {
/// Midnight UTC of the day, in Unix milliseconds. The key, and the x axis.
pub day_unix_ms: i64,
/// Wire bytes accepted that day.
pub bytes_in: u64,
/// Wire bytes served that day.
pub bytes_out: u64,
pub pushes: u64,
pub fetches: u64,
/// LFS object bytes as the LFS session counted them. **Also inside
/// `bytes_in`/`bytes_out`** as wire bytes: this is the applied figure and
/// that is the wire figure, two numbers about one transfer, so subtracting
/// one from the other is not a thing to do with them.
pub lfs_bytes_in: u64,
pub lfs_bytes_out: u64,
/// Transfers turned away that day, whatever the reason.
pub refused: u64,
/// **Only the reasons that actually happened** — `admission` (the box was
/// full), `cap` (the plan refused before a byte), `policy` (the push gate
/// refused). A reason gunnar did not send is ABSENT and is never written
/// out as 0: "nothing was refused by policy" and "this box does not break
/// refusals down" are different facts, and a fabricated zero merges them.
///
/// A `BTreeMap` because prost hands the map over as a `HashMap`, whose
/// iteration order changes on every process start; a JSON body that
/// reshuffles between two reads of one unchanged day is a diff nobody can
/// review. Same rule as [`Usage::enforcement`].
pub refused_by_reason: BTreeMap<String, u64>,
/// Principals that authenticated and then held no grant on what they asked
/// for. A key that resolves to nobody is NOT here — it happens before any
/// repository is named, so it belongs to no namespace.
pub auth_failures: u64,
/// Distinct principals seen that day: an exact count, bounded at 10 000.
pub distinct_principals: u64,
/// The count above is a FLOOR, not a total. Never true on a real namespace;
/// it exists so that if it ever is, the number says so instead of quietly
/// flattening.
pub distinct_principals_capped: bool,
/// The last push OF THAT DAY. `None` when the day had none — the same
/// zero-is-not-1970 rule as everywhere else on this route.
pub last_push_unix_ms: Option<i64>,
/// The last fetch of that day. `None` when the day had none.
pub last_fetch_unix_ms: Option<i64>,
}
/// The series for one namespace, as the console sends it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Activity {
pub namespace: String,
/// How many days this gunnar keeps (400 today). The page needs it to tell
/// an empty answer that means *nothing moved* from one that means *you
/// asked about a time this box no longer remembers*.
pub retained_days: i64,
/// **Oldest first, gaps left as gaps.** See [`activity`].
pub days: Vec<Day>,
}
/// gunnar's day row → the console's shape. Carried, not computed.
pub fn render_day(day: pb::DayActivity) -> Day {
Day {
day_unix_ms: day.day_unix_ms,
bytes_in: day.bytes_in,
bytes_out: day.bytes_out,
pushes: day.pushes,
fetches: day.fetches,
lfs_bytes_in: day.lfs_bytes_in,
lfs_bytes_out: day.lfs_bytes_out,
refused: day.refused,
// Sorted, and filtered by NOTHING: every key gunnar sent is carried
// and no key it did not send is invented. gunnar already omits the
// reasons with no refusals behind them.
refused_by_reason: day.refused_by_reason.into_iter().collect(),
auth_failures: day.auth_failures,
distinct_principals: day.distinct_principals,
distinct_principals_capped: day.distinct_principals_capped,
last_push_unix_ms: crate::api::recorded(day.last_push_unix_ms),
last_fetch_unix_ms: crate::api::recorded(day.last_fetch_unix_ms),
}
}
/// `GET /api/namespaces/{name}/activity`
///
/// One namespace, one `Usage.Activity`, the last [`ACTIVITY_DAYS`] UTC days.
///
/// **The grant is the same one `/usage` needs** — `Read` on the namespace —
/// and, as there, the console invents no second rule: gunnar's
/// `PermissionDenied` reaches the browser as a 403 in gunnar's own words
/// carrying no day rows at all, and its `NotFound` reaches it as a 404.
///
/// **The rows are passed through untouched: oldest first, and a day with no
/// traffic is absent rather than zero-filled.** Filling the gaps in here is the
/// tempting bug — a graph with a bar for every day is easier to draw — and it
/// would make this console assert a measurement gunnar never took. It also
/// destroys the distinction the page is built on: an empty `days` beside a
/// non-zero `retained_days` means *nothing moved*, and only a refusal means
/// *this could not be read*.
pub async fn activity(
reader: Reader,
Path(namespace): Path<String>,
) -> ApiResult<Activity> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default();
// The caller's own credential — see `read` above. A day-by-day series of
// what a namespace moved is as much that tenant's business as the totals.
let response = reader
.gunnar
.usage()
.activity(pb::ActivityRequest {
namespace: namespace.clone(),
since_unix_ms: window_start(now, ACTIVITY_DAYS),
// 0 is gunnar's own word for "now". Sending this console's clock
// instead would drop today's row whenever the two machines
// disagree by a few seconds across midnight, and the console's
// clock is the one with no reason to be right.
until_unix_ms: 0,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(Activity {
namespace,
retained_days: response.retained_days,
days: response.days.into_iter().map(render_day).collect(),
}))
}
/// One kind of work's admission plan, as the console shows it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdmitKind {
/// `clone`, `fetch`, `push`, `request`.
pub kind: String,
/// `Full`, `Reduced`, `Floor` or `Queued` — gunnar's words.
pub class: String,
/// gunnar's sentence, verbatim: "100 GB/30d used 63 GB; this clone ~1.2 GB; Full".
pub why: String,
/// Learnt bytes (in + out) per transfer of this kind. `None` when gunnar
/// has no estimate yet — a 0 would read as "costs nothing".
pub estimate_bytes: Option<u64>,
/// Finished transfers the estimate learnt from since the server started.
pub samples: u64,
}
/// `GET /api/namespaces/{name}/admit` — the answer, as the console sends it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdmitPlan {
pub namespace: String,
/// `pass-through` (no budget; every kind Full) or `usage-share`.
pub policy: String,
pub window_days: u32,
/// Bytes moved (in + out) over the window, today included.
pub used_bytes: u64,
/// `None` = no budget on this box. Never 0 for "none".
pub limit_bytes: Option<u64>,
/// `None` when there is no budget.
pub budget_left_bytes: Option<u64>,
pub kinds: Vec<AdmitKind>,
}
/// gunnar's plan → the console's shape. Carried, not computed.
pub fn render_admit(namespace: String, plan: pb::AdmitPlanResponse) -> AdmitPlan {
AdmitPlan {
namespace,
policy: plan.policy,
window_days: plan.window_days,
used_bytes: plan.used_bytes,
limit_bytes: plan.limit_bytes,
budget_left_bytes: plan.budget_left_bytes,
kinds: plan
.kinds
.into_iter()
.map(|k| AdmitKind {
kind: k.kind,
class: k.admit_class,
why: k.why,
estimate_bytes: (k.estimate_bytes > 0).then_some(k.estimate_bytes),
samples: k.samples,
})
.collect(),
}
}
/// `GET /api/namespaces/{name}/admit`
///
/// One namespace, one `Usage.AdmitPlan`: what the namespace has moved over
/// gunnar's rolling window, its budget, and per kind of work the class the
/// admission gate would give the next transfer and gunnar's sentence saying
/// why. For the admin view and for the tenant alike.
///
/// **The grant is the same one `/usage` and `/activity` need** — `Read` on the
/// namespace — and the console invents no second rule: gunnar's refusal
/// reaches the browser in gunnar's own words and carries no plan.
pub async fn admit_plan(
reader: Reader,
State(_state): State<AppState>,
Path(namespace): Path<String>,
) -> ApiResult<AdmitPlan> {
let response = reader
.gunnar
.usage()
.admit_plan(pb::AdmitPlanRequest {
namespace: namespace.clone(),
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render_admit(namespace, response)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::Arc;
use tower::ServiceExt as _;
use crate::pb::admin_server::{Admin as AdminService, AdminServer};
use crate::pb::usage_server::{Usage as UsageService, UsageServer};
/// The meters of one namespace, with **seven different numbers**.
///
/// Every value is distinct and none is a round multiple of another, so a
/// field crossed with its neighbour is visible. A fixture of zeroes — or of
/// one repeated number — would pass against a handler that read the wrong
/// field, against one that read the same field seven times, and against one
/// that returned a default: the identity value that proves nothing, and a
/// live hazard rather than a hypothetical, because `lfs_bytes` really is 0
/// on every gunnar there is.
fn meters(namespace: &str) -> pb::NamespaceUsage {
pb::NamespaceUsage {
namespace: namespace.to_owned(),
pack_bytes: 7_340_032,
cache_bytes: 1_572_864,
cold_bytes: 5_242_880,
// The one that is genuinely 0 everywhere today, kept 0 so the
// rendering of a real zero is exercised beside seven non-zeroes.
lfs_bytes: 0,
tombstoned_bytes: 3_145_728,
repos: 9,
objects: 41_237,
measured_at_unix_ms: 1_788_000_000_000,
// ── the flow half ────────────────────────────────────────────
//
// Distinct again, and for the same reason: five counters that a
// handler could cross with each other or with the stock meters
// above. `refused_today` is deliberately NOT the sum of anything
// else here, so a console that computed it instead of carrying it
// could not pass.
bytes_in_today: 811_923,
bytes_out_today: 4_402_117,
pushes_today: 3,
fetches_today: 27,
refused_today: 2,
last_push_unix_ms: 1_787_999_100_000,
last_fetch_unix_ms: 1_787_999_940_000,
}
}
/// **The allowance fixture: every cap DIFFERENT from every meter, and each
/// arm of the `remaining` rule reachable.**
///
/// Against [`meters`] and [`the_box`] (servable 2 MiB):
///
/// * `pack_bytes` cap 8 MiB, 7 MiB used → 1 MiB left, UNDER the box: the
/// cap is what binds;
/// * `tombstoned_bytes` cap 100 MB, 3 MiB used → 96 MB left, OVER the box:
/// the box is what binds, and `remaining` is the box's 2 MiB;
/// * `cold_bytes` cap == used → 0 left, a real zero, the cap reached;
/// * `cache_bytes`, `lfs_bytes`: NO cap → `remaining` is the box alone;
/// * `repos` cap 12, 9 used → 3, a count and not bytes, so the box does
/// not enter; `objects`: no cap on a count → nothing bounds it.
///
/// The enforcement map is gunnar's own table
/// (`gunnar_grpc::service::meters::METERS`): all seven meters, capped or
/// not, three walls and four readings. It is spelled out here rather than
/// derived so that a change on gunnar's side has to be copied in
/// deliberately, the way [`super::enforcement_word`]'s vocabulary is.
fn caps() -> pb::Allowance {
pb::Allowance {
enforcement: table(),
pack_bytes: Some(8_388_608),
cache_bytes: None,
cold_bytes: Some(5_242_880),
lfs_bytes: None,
tombstoned_bytes: Some(100_000_000),
repos: Some(12),
objects: None,
}
}
/// gunnar's seven meters and what a cap on each does there. `repos` is the
/// auditor's own example — a cap of five with nothing refusing the sixth —
/// and `pack_bytes` its opposite, so a fixture that flattened the two
/// could not pass anything below.
fn table() -> std::collections::HashMap<String, i32> {
[
("pack_bytes", pb::Enforcement::Enforced),
("cache_bytes", pb::Enforcement::Enforced),
("cold_bytes", pb::Enforcement::Measured),
("lfs_bytes", pb::Enforcement::Enforced),
("tombstoned_bytes", pb::Enforcement::Measured),
("repos", pb::Enforcement::Measured),
("objects", pb::Enforcement::Measured),
]
.into_iter()
.map(|(meter, word)| (meter.to_owned(), word as i32))
.collect()
}
/// The box: a 100 GiB set with 1 GiB and 2 MiB free, a 1 GiB floor, so
/// exactly 2 MiB servable — SEALED, the appliance's answer.
fn the_box() -> pb::CapacityResponse {
pb::CapacityResponse {
verdict: pb::CapacityVerdict::CapacityAvailable as i32,
growth: pb::SetGrowth::GrowthSealed as i32,
root: "/var/lib/gunnar".to_owned(),
total_bytes: 107_374_182_400,
free_bytes: 1_075_838_976,
floor_bytes: 1_073_741_824,
servable_bytes: 2_097_152,
reason: "YES".to_owned(),
..Default::default()
}
}
/// A gunnar that counts, and that refuses the way a real one does.
///
/// The grant table is the point. gunnar's `Usage.Read` takes `Read` on the
/// namespace it names, and `Usage.ReadAll` takes a server-wide `Read`; this
/// answers `PermissionDenied` for anything outside `readable`, in gunnar's
/// own words, so the console's refusal path can be driven without a server.
///
/// `Admin.Capacity` is served too — or refused, when `capacity` is an
/// error — because the box line and the box term of `remaining` come from
/// it and the page must be right both with and without it.
#[derive(Clone)]
struct FakeGunnar {
/// **Every fingerprint that signed a `Usage.Read`**, in order.
///
/// The only way a test can see WHICH of the console's credentials made
/// a call. `readable` proves gunnar's refusal reaches the browser; this
/// proves the console asked as the right principal in the first place
/// — and those are two different questions, because a console that
/// asks with a server-wide admin key is never refused at all, so a
/// `readable` that excluded the namespace would still let the read
/// through.
signed_by: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
readable: Vec<&'static str>,
rows: BTreeMap<&'static str, pb::NamespaceUsage>,
allowances: BTreeMap<&'static str, pb::Allowance>,
capacity: Result<pb::CapacityResponse, tonic::Code>,
/// The ledger `Usage.Activity` answers from, per namespace, **oldest
/// first with the quiet days already missing** — which is the shape
/// gunnar sends and therefore the only shape worth testing against.
days: BTreeMap<&'static str, Vec<pb::DayActivity>>,
}
impl FakeGunnar {
/// Counts `rows`, caps nobody, and cannot say what the box holds — the
/// community appliance seen through a key with no server-wide grant.
fn counting(
readable: Vec<&'static str>,
rows: BTreeMap<&'static str, pb::NamespaceUsage>,
) -> FakeGunnar {
FakeGunnar {
signed_by: Default::default(),
readable,
rows,
allowances: BTreeMap::new(),
capacity: Err(tonic::Code::PermissionDenied),
days: BTreeMap::new(),
}
}
}
#[tonic::async_trait]
impl AdminService for FakeGunnar {
async fn capacity(
&self,
request: tonic::Request<pb::CapacityRequest>,
) -> Result<tonic::Response<pb::CapacityResponse>, tonic::Status> {
let asked = request.into_inner();
// The console wants the measurement, not a verdict about a raise:
// a request naming bytes or a namespace here would be asking the
// seller's question on a page that has no seller.
assert_eq!(
asked.requested_bytes, 0,
"the page asks for the box, not for a raise"
);
assert!(
asked.namespace.is_empty(),
"the page asks for the box, not for a tenant"
);
match &self.capacity {
Ok(answer) => Ok(tonic::Response::new(answer.clone())),
Err(code) => Err(tonic::Status::new(
*code,
"principal gunnar-ui has no server-wide read grant (Admin.Capacity)",
)),
}
}
async fn health(
&self,
_: tonic::Request<pb::HealthRequest>,
) -> Result<tonic::Response<pb::HealthResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
async fn stats(
&self,
_: tonic::Request<pb::StatsRequest>,
) -> Result<tonic::Response<pb::StatsResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
async fn storage_info(
&self,
_: tonic::Request<pb::StorageInfoRequest>,
) -> Result<tonic::Response<pb::StorageInfoResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
async fn verify_chain(
&self,
_: tonic::Request<pb::VerifyChainRequest>,
) -> Result<tonic::Response<pb::VerifyChainResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
async fn grow_start(
&self,
_: tonic::Request<pb::GrowStartRequest>,
) -> Result<tonic::Response<pb::GrowStatusResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
async fn grow_status(
&self,
_: tonic::Request<pb::GrowStatusRequest>,
) -> Result<tonic::Response<pb::GrowStatusResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
async fn grow_resume(
&self,
_: tonic::Request<pb::GrowResumeRequest>,
) -> Result<tonic::Response<pb::GrowStatusResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("not this page's"))
}
}
// ── the data set (`GET /api/dataset`) ───────────────────────────────────
/// The appliance's shape: four 2 GiB members striped, xfs on top.
fn the_data_set_box() -> pb::CapacityResponse {
pb::CapacityResponse {
verdict: pb::CapacityVerdict::CapacityAvailable as i32,
growth: pb::SetGrowth::GrowthAtRestart as i32,
root: "/data".to_owned(),
total_bytes: 8_578_400_256,
free_bytes: 8_294_146_048,
floor_bytes: 2_147_483_648,
servable_bytes: 6_146_662_400,
data_set: "4\u{d7}2.0G raid0 xfs".to_owned(),
reason: "YES".to_owned(),
..Default::default()
}
}
fn box_only(capacity: Result<pb::CapacityResponse, tonic::Code>) -> FakeGunnar {
FakeGunnar {
signed_by: Default::default(),
readable: Vec::new(),
rows: BTreeMap::new(),
allowances: BTreeMap::new(),
capacity,
days: BTreeMap::new(),
}
}
#[test]
fn the_layout_words_split_into_their_parts_and_never_into_a_guess() {
assert_eq!(
layout_parts("4\u{d7}75G raid0 xfs"),
(Some(4), Some("75G".to_owned()), Some("raid0".to_owned()), Some("xfs".to_owned()))
);
assert_eq!(layout_parts("xfs"), (None, None, None, Some("xfs".to_owned())));
assert_eq!(layout_parts(""), (None, None, None, None));
// A shape this console does not know keeps no parts at all.
assert_eq!(layout_parts("x\u{d7}2G raid0 xfs"), (None, None, None, None));
assert_eq!(layout_parts("4 2G raid0 xfs"), (None, None, None, None));
}
#[test]
fn every_growth_answer_gunnar_can_give_has_a_word() {
assert_eq!(growth_word(pb::SetGrowth::GrowthSealed as i32), "sealed");
assert_eq!(growth_word(pb::SetGrowth::GrowthAtRestart as i32), "at_restart");
assert_eq!(growth_word(pb::SetGrowth::GrowthAtRuntime as i32), "at_runtime");
assert_eq!(growth_word(pb::SetGrowth::GrowthOperatorOnly as i32), "operator_only");
assert_eq!(growth_word(pb::SetGrowth::GrowthUnknown as i32), "unknown");
assert_eq!(growth_word(99), "unknown");
}
/// ★ **The data set on the wire is gunnar's measurement, field for field.**
///
/// End to end: a real `Admin` service on a real port, the console's router,
/// its gRPC client, its JSON. Total and free are gunnar's; used is the one
/// derived number; the layout is gunnar's words and their parts.
#[tokio::test]
async fn the_data_set_is_the_box_gunnar_measured() {
let app = console(box_only(Ok(the_data_set_box()))).await;
let (status, body) = get(&app, "/api/dataset").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let json: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(json["root"], "/data");
assert_eq!(json["total_bytes"], 8_578_400_256u64);
assert_eq!(json["free_bytes"], 8_294_146_048u64);
assert_eq!(json["used_bytes"], 8_578_400_256u64 - 8_294_146_048u64);
assert_eq!(json["layout"], "4\u{d7}2.0G raid0 xfs");
assert_eq!(json["members"], 4);
assert_eq!(json["member_size"], "2.0G");
assert_eq!(json["level"], "raid0");
assert_eq!(json["filesystem"], "xfs");
assert_eq!(json["growth"], "at_restart");
// The open console's own JSON: no money anywhere in it, keys or values.
let lower = body.to_lowercase();
for word in ["price", "buy", "sell", "sold", "quota", "plan", "billing", "billed", "pay"] {
assert!(
!lower
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|w| w == word),
"`{word}` in the data-set JSON: {body}"
);
}
}
/// ★ **The box's own allocation is Administration → Machine's read**
/// (regression audit 2026-10-01: lane-allocbuy e349616 drew disk, cores and
/// RAM on a tenant's usage page and never reached a release; the owner's
/// 2026-10-02 rule puts the machine under Administration). Cores and memory
/// are gunnar's measurement, field for field; a field gunnar did not
/// measure stays absent, never 0.
#[tokio::test]
async fn the_box_allocation_is_an_administrators_read_with_cores_and_memory() {
let measured = pb::CapacityResponse {
cpu_millicores_total: Some(4000),
cpu_millicores_busy: Some(10),
ram_bytes_total: Some(16_000_000_000),
ram_bytes_available: None,
..the_data_set_box()
};
let app = console(box_only(Ok(measured))).await;
let (status, body) = get(&app, "/api/machine/allocation").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let json: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(json["total_bytes"], 8_578_400_256u64);
assert_eq!(json["cpu_millicores_total"], 4000);
assert_eq!(json["cpu_millicores_busy"], 10);
assert_eq!(json["ram_bytes_total"], 16_000_000_000u64);
assert!(json["ram_bytes_available"].is_null(), "unmeasured is absent, never 0: {body}");
}
/// gunnar's refusal reaches the browser as itself — and no numbers with it.
#[tokio::test]
async fn a_key_without_a_server_wide_read_gets_gunnars_refusal_and_no_numbers() {
let app = console(box_only(Err(tonic::Code::PermissionDenied))).await;
let (status, body) = get(&app, "/api/dataset").await;
assert_eq!(status, http::StatusCode::FORBIDDEN, "{body}");
assert!(body.contains("no server-wide read grant"), "{body}");
assert!(!body.contains("total_bytes"), "{body}");
}
#[tonic::async_trait]
impl UsageService for FakeGunnar {
async fn read(
&self,
request: tonic::Request<pb::ReadUsageRequest>,
) -> Result<tonic::Response<pb::ReadUsageResponse>, tonic::Status> {
// Recorded BEFORE the grant check, so a test can see which key
// asked even when the answer is a refusal.
// A person's call carries their identity-core session instead of a
// signature (IDENTITY-HOST): recorded as `session:<token>`.
let session = request
.metadata()
.get(crate::grpc::SESSION_METADATA_KEY)
.and_then(|v| v.to_str().ok())
.map(|token| format!("session:{token}"));
self.signed_by.lock().expect("the fingerprint log").push(session.unwrap_or_else(|| {
request
.metadata()
.get(crate::signer::CREDENTIAL_METADATA_KEY)
.and_then(|v| v.to_str().ok())
.and_then(|header| header.split_whitespace().nth(1))
.unwrap_or("<none>")
.to_owned()
}));
let namespace = request.into_inner().namespace;
if !self.readable.iter().any(|n| *n == namespace) {
return Err(tonic::Status::permission_denied(format!(
"principal gunnar-ui has no read grant on namespace:{namespace}"
)));
}
match self.rows.get(namespace.as_str()) {
Some(row) => {
// Absent for a namespace with no entry, as gunnar sends it
// for a namespace with no fact: every cap absent.
let allowance = self
.allowances
.get(namespace.as_str())
.cloned()
.unwrap_or_default();
// gunnar's own `remaining`: cap − held, clamped at 0,
// absent where nothing caps the meter.
let left = |held: u64, cap: Option<u64>| cap.map(|c| c.saturating_sub(held));
let remaining = pb::Remaining {
pack_bytes: left(row.pack_bytes, allowance.pack_bytes),
cache_bytes: left(row.cache_bytes, allowance.cache_bytes),
cold_bytes: left(row.cold_bytes, allowance.cold_bytes),
lfs_bytes: left(row.lfs_bytes, allowance.lfs_bytes),
tombstoned_bytes: left(row.tombstoned_bytes, allowance.tombstoned_bytes),
repos: left(row.repos, allowance.repos),
objects: left(row.objects, allowance.objects),
};
Ok(tonic::Response::new(pb::ReadUsageResponse {
usage: Some(row.clone()),
allowance: Some(allowance),
remaining: Some(remaining),
repos: Vec::new(),
}))
}
None => Err(tonic::Status::not_found(format!(
"namespace {namespace} is not in the catalog"
))),
}
}
async fn read_all(
&self,
_request: tonic::Request<pb::ReadAllUsageRequest>,
) -> Result<tonic::Response<pb::ReadAllUsageResponse>, tonic::Status> {
// A credential that may read one namespace may NOT read every
// namespace, and gunnar says so. If the console ever answers this
// page by asking for everything and filtering, the row that reaches
// for it fails here rather than quietly working on an
// admin-credentialled deployment and breaking on a scoped one.
Err(tonic::Status::permission_denied(
"principal gunnar-ui has no server-wide read grant",
))
}
/// The series, behind the **same** grant `read` is behind.
///
/// That sameness is asserted rather than assumed: a fake that let
/// `Activity` through for a namespace `Read` refuses would make
/// `a_namespaces_activity_is_refused_and_leaks_no_days` unable to fail.
async fn activity(
&self,
request: tonic::Request<pb::ActivityRequest>,
) -> Result<tonic::Response<pb::ActivityResponse>, tonic::Status> {
let asked = request.into_inner();
if !self.readable.iter().any(|n| *n == asked.namespace) {
return Err(tonic::Status::permission_denied(format!(
"principal gunnar-ui has no read grant on namespace:{}",
asked.namespace
)));
}
// The window the console asked for, checked here because it is the
// only place it can be: the handler builds the request and nothing
// downstream would notice a console that asked for four hundred
// days to draw thirty bars, or that sent its own clock where
// gunnar's word for "now" belongs.
assert_eq!(
asked.until_unix_ms, 0,
"0 is gunnar's own word for now; sending the console's clock drops today's \
row whenever the two machines disagree across midnight"
);
assert!(
asked.since_unix_ms > 0,
"a `since` of 0 asks for the WHOLE kept window — four hundred rows to draw \
thirty bars"
);
assert_eq!(
asked.since_unix_ms % 86_400_000,
0,
"the window must start at midnight UTC, or the oldest bar is a part-day drawn \
at full height beside twenty-nine whole ones"
);
Ok(tonic::Response::new(pb::ActivityResponse {
days: self
.days
.get(asked.namespace.as_str())
.cloned()
.unwrap_or_default(),
retained_days: 400,
}))
}
/// The admission plan, behind the **same** grant `read` is behind.
async fn admit_plan(
&self,
request: tonic::Request<pb::AdmitPlanRequest>,
) -> Result<tonic::Response<pb::AdmitPlanResponse>, tonic::Status> {
let asked = request.into_inner();
if !self.readable.iter().any(|n| *n == asked.namespace) {
return Err(tonic::Status::permission_denied(format!(
"principal gunnar-ui has no read grant on namespace:{}",
asked.namespace
)));
}
Ok(tonic::Response::new(admit_answer()))
}
}
/// A usage-share plan with distinct numbers, and one kind with no estimate yet.
fn admit_answer() -> pb::AdmitPlanResponse {
pb::AdmitPlanResponse {
policy: "usage-share".into(),
window_days: 30,
used_bytes: 63_000_000_017,
limit_bytes: Some(100_000_000_000),
budget_left_bytes: Some(36_999_999_983),
kinds: vec![
pb::AdmitPlanKind {
kind: "clone".into(),
admit_class: "Full".into(),
why: "100 GB/30d used 63 GB; this clone ~1.2 GB; Full".into(),
estimate_bytes: 1_200_000_003,
estimate_cpu_ms: 0,
samples: 41,
},
pb::AdmitPlanKind {
kind: "push".into(),
admit_class: "Full".into(),
why: "100 GB/30d used 63 GB; this push ~? (no estimate yet); Full".into(),
estimate_bytes: 0,
estimate_cpu_ms: 0,
samples: 0,
},
],
}
}
/// ★ **The plan is carried as gunnar sent it**: the policy, the window,
/// the budget, gunnar's own sentence per kind — and a kind with no estimate
/// is `null`, never 0, which would read as "costs nothing".
#[tokio::test]
async fn the_admission_plan_is_gunnars_and_an_unlearnt_estimate_is_null() {
let app = console(FakeGunnar::counting(
vec!["alpha"],
BTreeMap::from([("alpha", meters("alpha"))]),
))
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/admit").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(seen["namespace"], "alpha");
assert_eq!(seen["policy"], "usage-share");
assert_eq!(seen["window_days"], 30);
assert_eq!(seen["used_bytes"], 63_000_000_017u64);
assert_eq!(seen["limit_bytes"], 100_000_000_000u64);
assert_eq!(seen["budget_left_bytes"], 36_999_999_983u64);
assert_eq!(seen["kinds"][0]["kind"], "clone");
assert_eq!(seen["kinds"][0]["class"], "Full");
assert_eq!(seen["kinds"][0]["why"], "100 GB/30d used 63 GB; this clone ~1.2 GB; Full");
assert_eq!(seen["kinds"][0]["estimate_bytes"], 1_200_000_003u64);
assert_eq!(seen["kinds"][0]["samples"], 41);
assert!(seen["kinds"][1]["estimate_bytes"].is_null(), "{body}");
}
/// A pass-through box has no budget, and says so with `null`, not 0.
#[test]
fn no_budget_is_null_and_not_zero() {
let plan = render_admit(
"alpha".into(),
pb::AdmitPlanResponse {
policy: "pass-through".into(),
window_days: 30,
used_bytes: 5,
limit_bytes: None,
budget_left_bytes: None,
kinds: vec![],
},
);
let json = serde_json::to_value(&plan).unwrap();
assert!(json["limit_bytes"].is_null() && json["budget_left_bytes"].is_null(), "{json}");
}
/// The plan of a namespace the caller cannot read is gunnar's refusal and
/// carries no number.
#[tokio::test]
async fn a_namespaces_admission_plan_is_refused_and_leaks_nothing() {
let app = console(FakeGunnar::counting(
vec!["alpha"],
BTreeMap::from([("alpha", meters("alpha")), ("beta", meters("beta"))]),
))
.await;
let (status, body) = get(&app, "/api/namespaces/beta/admit").await;
assert_eq!(status, http::StatusCode::FORBIDDEN, "{body}");
assert!(body.contains("no read grant on namespace:beta"), "{body}");
assert!(!body.contains("63000000017") && !body.contains("used_bytes"), "{body}");
}
/// Stand `gunnar` up on loopback and point a console at it.
///
/// Returns the console's router. The server lives as a detached task for
/// the process's lifetime, which is what a `#[tokio::test]` wants: nothing
/// to join, nothing to leak past the test binary.
async fn console(fake: FakeGunnar) -> axum::Router {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(UsageServer::new(fake.clone()))
.add_service(AdminServer::new(fake))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[7u8; 32]),
))
.expect("a fixed seed makes a signer"),
);
let endpoint = format!("http://{addr}");
let gunnar = crate::grpc::Gunnar::connect_lazy(&endpoint, signer).expect("a lazy channel");
let store = crate::state::StateStore::open(
tempfile::Builder::new()
.suffix(".redb")
.tempfile()
.expect("a temp file")
// Kept: the store outlives the `NamedTempFile` guard, and the
// test binary's temp directory is cleaned by the OS. Dropping
// the guard here would delete the file redb is holding open.
.into_temp_path()
.keep()
.expect("keep the path"),
)
.expect("a state store");
crate::serve::router(Arc::new(crate::app::App {
webauthn: None,
#[cfg(feature = "metered")]
monetize: None,
admin_token: None,
events: crate::sse::EventHub::detached(),
gunnar,
// No guest identity: these tests predate the anonymous surface and
// are about a logged-in console.
twin: None,
guest: None,
reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
endpoint.clone(),
store.clone(),
)),
tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store)),
forges: Default::default(),
imports: Default::default(),
forge_apps: Default::default(),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
}))
}
/// `GET` a path off the console and read the status and the body.
async fn get(app: &axum::Router, uri: &str) -> (http::StatusCode, String) {
let response = app
.clone()
.oneshot(
http::Request::builder()
.uri(uri)
.body(axum::body::Body::empty())
.expect("a request"),
)
.await
.expect("the console answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.expect("a readable body");
(status, String::from_utf8_lossy(&body).into_owned())
}
/// ★ **The numbers on the wire are gunnar's, field for field.**
///
/// End to end: a real `Usage` service on a real port, the console's own
/// router, its own gRPC client, its own JSON. Every meter is asserted
/// against the exact value the service sent, and the seven values are
/// different from each other so a crossed field cannot hide.
///
/// Seen red twice, both on the applied output:
///
/// * with `cold_bytes` written into `cache_bytes` in [`render`] —
/// *"assertion `left == right` failed: cache_bytes … left: 5242880 right:
/// 1572864"*;
/// * with the whole body replaced by `Usage::default()`-shaped zeroes —
/// *"pack_bytes … left: 0 right: 7340032"*, which is the failure a page
/// wired to nothing would produce and the one a fixture of zeroes could
/// never produce.
#[tokio::test]
async fn a_namespaces_meters_are_the_numbers_gunnar_counted() {
let app = console(FakeGunnar::counting(
vec!["alpha"],
BTreeMap::from([("alpha", meters("alpha"))]),
))
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/usage").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
// Read as JSON rather than back through this module's own struct: the
// browser sees field NAMES, and a rename that a `serde_json::from_str`
// into `Usage` would follow silently is a page of blanks.
let seen: serde_json::Value = serde_json::from_str(&body).expect("the console sends JSON");
let sent = meters("alpha");
assert_eq!(seen["namespace"], "alpha");
for (name, value) in [
("pack_bytes", sent.pack_bytes),
("cache_bytes", sent.cache_bytes),
("cold_bytes", sent.cold_bytes),
("tombstoned_bytes", sent.tombstoned_bytes),
("repos", sent.repos),
("objects", sent.objects),
] {
assert_eq!(
seen[name].as_u64(),
Some(value),
"{name} is not the number gunnar counted: {body}"
);
}
assert_eq!(
seen["measured_at_unix_ms"].as_i64(),
Some(sent.measured_at_unix_ms),
"measured_at"
);
// The real zero, carried as a zero. gunnar has no LFS store, so this
// meter is 0 on every server there is — which is why the six above are
// not, and why this one is asserted separately rather than being the
// test's only evidence.
assert_eq!(seen["lfs_bytes"].as_u64(), Some(0), "lfs_bytes");
// And no meter was invented: `pack_bytes` is gunnar's own walk, not the
// sum of the tiers beside it. If the console ever starts computing it,
// this is what says so.
assert_ne!(
sent.pack_bytes,
sent.cache_bytes + sent.cold_bytes,
"the fixture itself must not make the sum accidentally true"
);
}
/// ★ **A namespace the caller may not read yields no numbers.**
///
/// The refusal red. gunnar answers `PermissionDenied` for a namespace
/// outside the caller's grants, and the console must relay that as a
/// refusal — since 2026-09-28 the one 404 `api::absent` gives a name that
/// does not exist, so the refusal says nothing about the name — not fall back to a wider call, not answer
/// 200 with zeroes, and above all not let a single byte of that namespace's
/// figures into the response.
///
/// Seen red by having the handler swallow the refusal and answer
/// `Json(Usage { namespace, ..zeroes })` on error: *"a refused namespace
/// answered 200 OK"*. Seen red a second way by pointing the handler at
/// `read_all` and filtering, which the fake refuses server-wide: the row
/// then fails on the ALLOWED namespace too, which is the point — a console
/// that reads everything cannot serve a scoped credential at all.
#[tokio::test]
async fn a_namespace_the_caller_cannot_read_is_refused_and_leaks_nothing() {
let app = console(FakeGunnar::counting(
// `beta` exists and is counted. It is simply not this caller's.
vec!["alpha"],
BTreeMap::from([("alpha", meters("alpha")), ("beta", meters("beta"))]),
))
.await;
// The control: the same console, the same call, a namespace the caller
// MAY read. Without this the test below would pass against a console
// that refuses everything.
let (allowed, _) = get(&app, "/api/namespaces/alpha/usage").await;
assert_eq!(
allowed,
http::StatusCode::OK,
"the caller's own namespace must still be readable, or this proves nothing"
);
let (status, body) = get(&app, "/api/namespaces/beta/usage").await;
// Since 2026-09-28 a namespace the caller may not read is answered
// EXACTLY as one that does not exist (`api::absent`): 404 and one body.
// gunnar's words ("no read grant on namespace:beta") would tell the
// caller the name is real.
assert_eq!(
status,
http::StatusCode::NOT_FOUND,
"a refused namespace answered {status}: {body}"
);
assert_eq!(body, crate::api::absent::body().to_string());
// The numbers themselves. Rendered as JSON they would appear as bare
// decimal integers, so each is looked for as it would be written.
let sent = meters("beta");
for (name, value) in [
("pack_bytes", sent.pack_bytes),
("cache_bytes", sent.cache_bytes),
("cold_bytes", sent.cold_bytes),
("tombstoned_bytes", sent.tombstoned_bytes),
("objects", sent.objects),
] {
assert!(
!body.contains(&value.to_string()),
"the refusal carries beta's {name} ({value}): {body}"
);
}
}
/// ★ **The console asks about ONE namespace.**
///
/// `Usage.ReadAll` takes a server-wide `Read` and spans every tenant.
/// Answering this page from it would work perfectly on a console whose
/// credential is server-wide admin — which is the credential `gunnar.rs`
/// runs today — and fail on any narrower one, while having handed this
/// process every namespace's figures on the way.
///
/// The fake refuses `ReadAll` exactly as gunnar would for a scoped
/// credential, so a console that reached for it could not answer at all.
/// This asserts the positive: with `ReadAll` denied server-wide, the page's
/// call still succeeds and still carries the right namespace's numbers.
#[tokio::test]
async fn namespace_usage_asks_only_about_the_namespace_it_names() {
let app = console(FakeGunnar::counting(
vec!["alpha", "beta"],
BTreeMap::from([("alpha", meters("alpha")), ("beta", meters("beta"))]),
))
.await;
for namespace in ["alpha", "beta"] {
let (status, body) = get(&app, &format!("/api/namespaces/{namespace}/usage")).await;
assert_eq!(status, http::StatusCode::OK, "{namespace}: {body}");
let seen: serde_json::Value =
serde_json::from_str(&body).expect("the console sends JSON");
assert_eq!(
seen["namespace"], namespace,
"the answer must be about the namespace that was asked for"
);
}
}
/// A namespace gunnar does not have is a 404, not a page of zeroes.
///
/// The failure this rules out is the friendly one: an empty answer rendered
/// as an existing namespace holding nothing. A person reading this page to
/// find out where their disk went must be able to tell "no such namespace"
/// from "this namespace is empty".
#[tokio::test]
async fn a_namespace_that_is_not_in_the_catalog_is_not_shown_as_an_empty_one() {
let app = console(FakeGunnar::counting(
// Readable, so the refusal cannot be what answers.
vec!["ghost"],
BTreeMap::new(),
))
.await;
let (status, body) = get(&app, "/api/namespaces/ghost/usage").await;
assert_eq!(status, http::StatusCode::NOT_FOUND, "{body}");
// Not zeroes, and — since `api::absent` — the one body every hidden
// name gets, so "not in the catalog" and "not yours" read the same.
assert_eq!(body, crate::api::absent::body().to_string());
}
/// gunnar accepting the read and sending no meters is a fault, said out
/// loud, rather than a namespace rendered as seven zeroes.
#[tokio::test]
async fn an_answer_with_no_meters_in_it_is_not_rendered_as_zeroes() {
struct Empty;
#[tonic::async_trait]
impl UsageService for Empty {
async fn read(
&self,
_request: tonic::Request<pb::ReadUsageRequest>,
) -> Result<tonic::Response<pb::ReadUsageResponse>, tonic::Status> {
Ok(tonic::Response::new(pb::ReadUsageResponse {
usage: None,
..Default::default()
}))
}
async fn read_all(
&self,
_request: tonic::Request<pb::ReadAllUsageRequest>,
) -> Result<tonic::Response<pb::ReadAllUsageResponse>, tonic::Status> {
unreachable!("this page never asks for every namespace")
}
async fn activity(
&self,
_request: tonic::Request<pb::ActivityRequest>,
) -> Result<tonic::Response<pb::ActivityResponse>, tonic::Status> {
unreachable!("this fixture is about the meter read answering with no meters")
}
async fn admit_plan(
&self,
_request: tonic::Request<pb::AdmitPlanRequest>,
) -> Result<tonic::Response<pb::AdmitPlanResponse>, tonic::Status> {
unreachable!("this fixture is about the meter read answering with no meters")
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(UsageServer::new(Empty))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[8u8; 32]),
))
.expect("a signer"),
);
let gunnar = crate::grpc::Gunnar::connect_lazy(&format!("http://{addr}"), signer)
.expect("a lazy channel");
let store = crate::state::StateStore::open(
tempfile::Builder::new()
.suffix(".redb")
.tempfile()
.expect("a temp file")
.into_temp_path()
.keep()
.expect("keep the path"),
)
.expect("a state store");
let state = Arc::new(crate::app::App {
webauthn: None,
#[cfg(feature = "metered")]
monetize: None,
admin_token: None,
events: crate::sse::EventHub::detached(),
gunnar,
// No guest identity: these tests predate the anonymous surface and
// are about a logged-in console.
twin: None,
guest: None,
reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
format!("http://{addr}"),
store.clone(),
)),
tenants: Arc::new(crate::tenant::TenantKeys::new(
format!("http://{addr}"),
store,
)),
forges: Default::default(),
imports: Default::default(),
forge_apps: Default::default(),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
});
// The console's own credential, which is what `Reader` hands an
// operator — this test is about a malformed ANSWER, not about who
// asked.
let reader = Reader {
gunnar: state.gunnar.clone(),
guest: false,
tenant: false,
anonymous: false,
};
let failed = read(reader, State(state), Path("alpha".to_owned()))
.await
.expect_err("no meters is not an answer");
assert!(
failed.0.message.contains("no meters"),
"the fault must name what was missing: {}",
failed.0.message
);
}
/// The mapping itself, field by field, away from the wire.
///
/// The end-to-end rows above would catch a crossed field too; this says
/// which one in one line when they do, and it is where the timestamp rule
/// is pinned: gunnar leaves several of its timestamps at zero and a zero
/// rendered relatively reads as "689 months ago".
#[test]
fn a_measurement_gunnar_never_took_is_absent_rather_than_1970() {
let rendered = render(
pb::NamespaceUsage {
measured_at_unix_ms: 0,
..meters("alpha")
},
pb::Allowance::default(),
None,
Err("no box".to_owned()),
);
assert_eq!(rendered.measured_at_unix_ms, None);
// …and a real one survives, or the rule above would be "always None".
assert_eq!(
render(
meters("alpha"),
pb::Allowance::default(),
None,
Err("no box".to_owned())
)
.measured_at_unix_ms,
Some(1_788_000_000_000)
);
}
// ── the allowance, and what is left ───────────────────────────────────
/// The JSON of one meter's three numbers, as the browser reads them.
fn meter(seen: &serde_json::Value, name: &str) -> (Option<u64>, Option<u64>, Option<u64>) {
(
seen[name].as_u64(),
seen["caps"][name].as_u64(),
seen["remaining"][name].as_u64(),
)
}
/// ★ **Used, allowed, remaining — with the box: the cap is carried as it
/// came, `remaining` is gunnar's own answer (thin layer, owner
/// 2026-10-04), and the box is carried beside it, never folded in.**
///
/// Every arm of the rule, each on a meter whose numbers no other meter
/// shares (see [`caps`]). Seen red three ways on the applied JSON: with
/// `remaining` computed as `cap − used` and the box ignored —
/// *"tombstoned_bytes remaining: left Some(96854272) right Some(2097152)"*;
/// with a `None` cap rendered as `0` —
/// *"cache_bytes cap: left Some(0) right None"*; and with the box's
/// `free_bytes` sent where `servable_bytes` belongs —
/// *"cache_bytes remaining: left Some(1075838976) right Some(2097152)"*.
#[tokio::test]
async fn remaining_is_gunnars_answer_and_the_box_is_carried_beside_it() {
let app = console(FakeGunnar {
signed_by: Default::default(),
readable: vec!["alpha"],
rows: BTreeMap::from([("alpha", meters("alpha"))]),
allowances: BTreeMap::from([("alpha", caps())]),
capacity: Ok(the_box()),
days: BTreeMap::new(),
})
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/usage").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
// (used, cap, remaining) per meter — every triple different, every
// arm of the rule on its own line.
// `remaining` is gunnar's (cap − held, absent where uncapped) and the
// box is NOT folded into it: the box is its own field, below.
let expect = [
("pack_bytes", 7_340_032, Some(8_388_608), Some(1_048_576)),
("cache_bytes", 1_572_864, None, None),
("cold_bytes", 5_242_880, Some(5_242_880), Some(0)),
("lfs_bytes", 0, None, None),
("tombstoned_bytes", 3_145_728, Some(100_000_000), Some(96_854_272)),
("repos", 9, Some(12), Some(3)),
("objects", 41_237, None, None),
];
for (name, used, cap, left) in expect {
let (u, c, l) = meter(&seen, name);
assert_eq!(u, Some(used), "{name} used: {body}");
assert_eq!(c, cap, "{name} cap: {body}");
assert_eq!(l, left, "{name} remaining: {body}");
}
// `null`, not 0, not missing: the browser tells "no cap" from "a cap
// of nothing" by the field being present and null.
assert!(seen["caps"]["cache_bytes"].is_null(), "{body}");
assert!(
seen["caps"].get("cache_bytes").is_some(),
"the field is present: {body}"
);
assert!(seen["remaining"]["objects"].is_null(), "{body}");
// The box itself, field for field, with the growth word.
assert_eq!(
seen["box"]["total_bytes"].as_u64(),
Some(107_374_182_400),
"{body}"
);
assert_eq!(
seen["box"]["free_bytes"].as_u64(),
Some(1_075_838_976),
"{body}"
);
assert_eq!(
seen["box"]["servable_bytes"].as_u64(),
Some(2_097_152),
"{body}"
);
assert_eq!(seen["box"]["growth"], "sealed", "{body}");
assert_eq!(seen["box_unreadable"], "", "{body}");
}
/// ★ **Without the box — a key that may read the namespace and not the
/// server — the caps are unchanged, `remaining` is `cap − used` alone, no
/// cap on a byte meter leaves nothing to say, and the page is still 200
/// with gunnar's reason for the missing box on it.**
///
/// Seen red with the refusal propagated as the route's error — *"a
/// refused box answered 403 Forbidden"* — and with `remaining` for an
/// uncapped meter filled with 0 in place of `null` —
/// *"cache_bytes remaining: left Some(0) right None"*.
#[tokio::test]
async fn without_the_box_remaining_is_what_the_cap_leaves_and_the_reason_is_on_the_wire() {
let app = console(FakeGunnar {
signed_by: Default::default(),
readable: vec!["alpha"],
rows: BTreeMap::from([("alpha", meters("alpha"))]),
allowances: BTreeMap::from([("alpha", caps())]),
capacity: Err(tonic::Code::PermissionDenied),
days: BTreeMap::new(),
})
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/usage").await;
assert_eq!(
status,
http::StatusCode::OK,
"a refused box answered {status}: {body}"
);
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
let expect = [
("pack_bytes", Some(8_388_608), Some(1_048_576)),
("cache_bytes", None, None),
("cold_bytes", Some(5_242_880), Some(0)),
("lfs_bytes", None, None),
("tombstoned_bytes", Some(100_000_000), Some(96_854_272)),
("repos", Some(12), Some(3)),
("objects", None, None),
];
for (name, cap, left) in expect {
let (_, c, l) = meter(&seen, name);
assert_eq!(c, cap, "{name} cap: {body}");
assert_eq!(l, left, "{name} remaining: {body}");
}
assert!(seen["box"].is_null(), "{body}");
assert!(
seen["box_unreadable"]
.as_str()
.is_some_and(|why| why.contains("no server-wide read grant")),
"gunnar's own words for the missing box: {body}"
);
}
/// A gunnar from before the allowance sends none, and that is every cap
/// absent — the same answer it would give if asked. Not an error, not 0.
#[test]
fn a_gunnar_that_sends_no_allowance_is_every_cap_absent() {
let rendered = render(
meters("alpha"),
pb::Allowance::default(),
None,
Err("no box".to_owned()),
);
assert_eq!(rendered.caps, PerMeter::default());
assert_eq!(rendered.remaining, PerMeter::default());
assert_eq!(rendered.machine, None);
assert_eq!(rendered.box_unreadable, "no box");
}
/// ★ **A cap goes out with the word for what it DOES, and the word is a
/// word.**
///
/// The defect: `Usage.Read` has reported seven caps in one shape under one
/// message called `Allowance`, and three of them are a door. gunnar closed
/// its half on 2026-09-07 (`Allowance.enforcement`); this console's mirror
/// predated it, so the field arrived and nothing carried it to the browser.
///
/// Seen RED against the tree as it stood — the compiler first, because the
/// re-vendored proto grew a field the fixture did not fill:
///
/// ```text
/// error[E0063]: missing field `enforcement` in initializer of `Allowance`
/// --> crates/gunnar-ui-server/src/api/usage.rs:374:9
/// ```
///
/// and then, with the fixture filled and [`render`] still ignoring it —
/// note `"enforcement":{}` in the body, which is precisely the shape an
/// older gunnar's answer has and the reason the two cases needed
/// separating:
///
/// ```text
/// ---- api::usage::tests::a_cap_carries_the_word_for_what_it_does stdout ----
/// assertion `left == right` failed: pack_bytes is a cap and the console says
/// nothing about what happens at it: {"namespace":"alpha",… ,"caps":
/// {"pack_bytes":8388608,…},"enforcement":{},"remaining":{…}}
/// left: None
/// right: Some("enforced")
/// ```
///
/// The assertion is on the JSON, not on the struct: the browser reads field
/// names and values, and a `serde_json::from_str` back into [`Usage`] would
/// follow a rename this console made on both sides at once.
#[tokio::test]
async fn a_cap_carries_the_word_for_what_it_does() {
let app = console(FakeGunnar {
signed_by: Default::default(),
readable: vec!["alpha"],
rows: BTreeMap::from([("alpha", meters("alpha"))]),
allowances: BTreeMap::from([("alpha", caps())]),
capacity: Ok(the_box()),
days: BTreeMap::new(),
})
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/usage").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
for (meter, word) in [
("pack_bytes", "enforced"),
("cache_bytes", "enforced"),
("cold_bytes", "measured"),
("lfs_bytes", "enforced"),
("tombstoned_bytes", "measured"),
// The auditor's example: a cap of 12 repositories that nothing on
// the box refuses the thirteenth of.
("repos", "measured"),
("objects", "measured"),
] {
assert_eq!(
seen["enforcement"][meter].as_str(),
Some(word),
"{meter} is a cap and the console says nothing about what happens at it: \
{body}"
);
}
// **A WORD, never the wire number.** `2` on this JSON would be an
// enum's ordinal published as an API value, and renumbering the proto
// would relabel every wall a reading with nothing failing.
assert!(
seen["enforcement"]["pack_bytes"].is_string(),
"the enforcement is gunnar's enum ORDINAL on the console's own JSON: {body}"
);
// And the word travels beside the cap, not instead of it: the tenant
// bought the number.
assert_eq!(seen["caps"]["repos"].as_u64(), Some(12), "{body}");
}
/// ★ **An older gunnar says nothing, and nothing is not "nothing is
/// enforced".**
///
/// The appliance is being re-imaged and will answer the map. A
/// self-hoster's box will not — and that box still cuts a push mid-stream
/// at `pack_bytes`. Rendering its silence as seven readings would tell its
/// operator their pack quota is a line item, and the truth would arrive as
/// a refused push.
///
/// So the empty map is carried out EMPTY. It is the page's job to say "not
/// said" (`pages::usage::wall_or_reading`), and it can only do that if the
/// server does not fill the silence in for it.
///
/// Seen red against a [`render`] that defaulted the missing map to the
/// seven words this build happens to know:
/// *"an allowance with no enforcement in it was rendered as 7 words; a box
/// that did not say must not be quoted"*.
#[test]
fn a_gunnar_that_says_nothing_about_enforcement_is_not_read_as_nothing_enforced() {
let silent = pb::Allowance {
// It DOES hold a cap — this is not the uncapped case. An older
// gunnar with a signed fact reports the number and no word for it.
pack_bytes: Some(8_388_608),
..pb::Allowance::default()
};
let rendered = render(meters("alpha"), silent, None, Err("no box".to_owned()));
assert_eq!(
rendered.caps.pack_bytes,
Some(8_388_608),
"the cap is still carried"
);
assert!(
rendered.enforcement.is_empty(),
"an allowance with no enforcement in it was rendered as {} words; a box that \
did not say must not be quoted: {:?}",
rendered.enforcement.len(),
rendered.enforcement
);
}
/// An enforcement code this build has no word for is left OUT rather than
/// given a placeholder, which puts it in the same box as an older gunnar's
/// silence — the one case the page already renders honestly.
///
/// `ENFORCEMENT_UNSPECIFIED` is a field nobody filled. A future variant is
/// a word this build has not learned. Neither is something to show a
/// tenant as a fact about their plan.
#[test]
fn a_word_this_build_does_not_know_is_left_unsaid() {
assert_eq!(
enforcement_word(pb::Enforcement::Enforced as i32),
Some("enforced")
);
assert_eq!(
enforcement_word(pb::Enforcement::Measured as i32),
Some("measured")
);
assert_eq!(
enforcement_word(pb::Enforcement::Provisioned as i32),
Some("provisioned"),
"gunnar answers only two of the three, but the vocabulary is monetize's whole \
one and a console that could not name the third would be a console that \
breaks on the day gunnar starts using it"
);
assert_eq!(enforcement_word(pb::Enforcement::Unspecified as i32), None);
assert_eq!(enforcement_word(99), None);
let rendered = render(
meters("alpha"),
pb::Allowance {
enforcement: [("repos".to_owned(), 99), ("pack_bytes".to_owned(), 2)]
.into_iter()
.collect(),
..pb::Allowance::default()
},
None,
Err("no box".to_owned()),
);
assert_eq!(
rendered.enforcement,
BTreeMap::from([("pack_bytes".to_owned(), "enforced".to_owned())]),
"a code with no word must be absent, not a placeholder"
);
}
/// gunnar's `remaining` is carried meter by meter, untouched — an absent
/// one stays absent, a 0 stays 0, and nothing is computed beside it.
#[test]
fn remaining_is_carried_from_gunnar_untouched() {
let rendered = render(
meters("alpha"),
caps(),
Some(pb::Remaining {
pack_bytes: Some(11),
cold_bytes: Some(0),
repos: Some(3),
..Default::default()
}),
Err("no box".to_owned()),
);
assert_eq!(rendered.remaining.pack_bytes, Some(11));
assert_eq!(rendered.remaining.cold_bytes, Some(0));
assert_eq!(rendered.remaining.repos, Some(3));
assert_eq!(rendered.remaining.cache_bytes, None);
assert_eq!(rendered.remaining.tombstoned_bytes, None, "gunnar sent none: none");
}
// ── the flow half: what moved ─────────────────────────────────────────
/// ★ **The five counters and the two "last seen" stamps are gunnar's, and
/// a namespace nothing has ever pushed to is not dated 1970.**
///
/// The defect this closes is the whole reason the flow fields exist: every
/// meter on this route before them was a STOCK meter, and a namespace
/// pushed to this morning read identically to one last touched in March.
///
/// The timestamp half is the subtle one. gunnar sends a bare `int64` and
/// uses 0 for *never pushed to inside the kept window*; proto3 cannot tell
/// that from an unset field, so a console that carried the number would put
/// **1 Jan 1970** — "689 months ago", on a page that renders ages
/// relatively — beside a namespace that has simply never been pushed to.
/// That is not a cosmetic wrong date: it is the difference between "quiet
/// since March" and "never used", which is the exact question the field was
/// added to answer.
///
/// Seen red twice on the applied JSON: with `pushes_today` read out of
/// `fetches_today` — *"pushes_today … left: Some(27) right: Some(3)"* — and
/// with the raw stamp carried instead of [`crate::api::recorded`] —
/// *"a namespace never pushed to is dated 1970: Some(0)"*.
#[tokio::test]
async fn what_moved_today_is_carried_and_a_namespace_never_pushed_to_is_not_dated_1970() {
let app = console(FakeGunnar::counting(
vec!["alpha", "quiet"],
BTreeMap::from([
("alpha", meters("alpha")),
// The namespace nothing has ever touched: every flow counter 0
// AND both stamps 0, which is the shape a brand-new namespace
// has and the shape an old gunnar sends for every namespace.
(
"quiet",
pb::NamespaceUsage {
bytes_in_today: 0,
bytes_out_today: 0,
pushes_today: 0,
fetches_today: 0,
refused_today: 0,
last_push_unix_ms: 0,
last_fetch_unix_ms: 0,
..meters("quiet")
},
),
]),
))
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/usage").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
let sent = meters("alpha");
for (name, value) in [
("bytes_in_today", sent.bytes_in_today),
("bytes_out_today", sent.bytes_out_today),
("pushes_today", sent.pushes_today),
("fetches_today", sent.fetches_today),
("refused_today", sent.refused_today),
] {
assert_eq!(
seen[name].as_u64(),
Some(value),
"{name} is not the number gunnar counted: {body}"
);
}
assert_eq!(
seen["last_push_unix_ms"].as_i64(),
Some(sent.last_push_unix_ms),
"a real stamp must survive, or the rule below would be \"always null\": {body}"
);
assert_eq!(
seen["last_fetch_unix_ms"].as_i64(),
Some(sent.last_fetch_unix_ms),
"{body}"
);
// And nothing was derived: `refused_today` is gunnar's own count and
// not a sum of anything on this page.
assert_ne!(
sent.refused_today,
sent.pushes_today + sent.fetches_today,
"the fixture itself must not make a derived answer accidentally true"
);
let (status, body) = get(&app, "/api/namespaces/quiet/usage").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
assert!(
seen["last_push_unix_ms"].is_null(),
"a namespace never pushed to is dated 1970: {:?} — {body}",
seen["last_push_unix_ms"]
);
assert!(
seen["last_fetch_unix_ms"].is_null(),
"a namespace never fetched from is dated 1970: {body}"
);
// The counters themselves are REAL zeroes and stay zeroes. A quiet day
// moved nothing, and that is a measurement, not a missing one.
assert_eq!(seen["pushes_today"].as_u64(), Some(0), "{body}");
assert_eq!(seen["bytes_in_today"].as_u64(), Some(0), "{body}");
}
// ── the series ────────────────────────────────────────────────────────
const DAY: i64 = 86_400_000;
/// Midnight UTC of an arbitrary but real day; the fixture's rows hang off
/// it. A multiple of [`DAY`] on purpose — a "day" that is not a midnight
/// would make the ordering assertions pass for the wrong reason.
const D0: i64 = 20_694 * DAY;
/// **Three days out of six, with the quiet days MISSING** — the shape
/// gunnar actually sends, and therefore the only shape worth a fixture.
///
/// `D0` is an ordinary day. `D0 + 2` is the day the tenant hit walls, and
/// its breakdown names `admission` and `cap` and **not `policy`**, so a
/// console that zero-filled the third reason has something to fail on.
/// `D0 + 5` is a fetch-only day, so its `last_push_unix_ms` is gunnar's
/// genuine 0 and the 1970 rule is exercised on a day row rather than only
/// on the glance.
///
/// Every number is distinct from every other, including across rows: a
/// handler that read day two's `bytes_out` into day one's `bytes_in` is
/// visible, and a fixture of round repeated numbers could not show it.
fn ledger() -> Vec<pb::DayActivity> {
vec![
pb::DayActivity {
day_unix_ms: D0,
bytes_in: 1_048_576,
bytes_out: 5_242_880,
pushes: 4,
fetches: 11,
lfs_bytes_in: 262_144,
lfs_bytes_out: 131_072,
refused: 0,
refused_by_reason: std::collections::HashMap::new(),
auth_failures: 0,
last_push_unix_ms: D0 + 41_000_000,
last_fetch_unix_ms: D0 + 62_000_000,
distinct_principals: 3,
distinct_principals_capped: false,
},
// The gap: `D0 + 1` has no row at all, because nothing moved.
pb::DayActivity {
day_unix_ms: D0 + 2 * DAY,
bytes_in: 3_145_728,
bytes_out: 786_432,
pushes: 2,
fetches: 7,
lfs_bytes_in: 0,
lfs_bytes_out: 65_536,
refused: 5,
// TWO reasons of the three. `policy` never happened, so gunnar
// does not send it, and neither may this console.
refused_by_reason: [("admission".to_owned(), 1u64), ("cap".to_owned(), 4u64)]
.into_iter()
.collect(),
auth_failures: 1,
last_push_unix_ms: D0 + 2 * DAY + 33_000_000,
last_fetch_unix_ms: D0 + 2 * DAY + 55_000_000,
distinct_principals: 2,
distinct_principals_capped: false,
},
pb::DayActivity {
day_unix_ms: D0 + 5 * DAY,
bytes_in: 0,
bytes_out: 9_437_184,
// Nobody pushed. gunnar's `last_push_unix_ms` for the day is
// its genuine 0.
pushes: 0,
fetches: 19,
lfs_bytes_in: 0,
lfs_bytes_out: 0,
refused: 0,
refused_by_reason: std::collections::HashMap::new(),
auth_failures: 0,
last_push_unix_ms: 0,
last_fetch_unix_ms: D0 + 5 * DAY + 71_000_000,
distinct_principals: 5,
distinct_principals_capped: false,
},
]
}
/// A gunnar that counts `alpha` and holds [`ledger`] for it.
fn with_ledger(readable: Vec<&'static str>) -> FakeGunnar {
FakeGunnar {
signed_by: Default::default(),
days: BTreeMap::from([("alpha", ledger())]),
..FakeGunnar::counting(
readable,
BTreeMap::from([("alpha", meters("alpha")), ("beta", meters("beta"))]),
)
}
}
/// ★ **The days arrive oldest first, untouched, gaps and all.**
///
/// Every field of every row against the exact value the service sent, in
/// the exact order it sent them — and the gap at `D0 + 1` is asserted as a
/// gap, because zero-filling it is the tempting bug: a bar for every day is
/// easier to draw, and it would make this console assert a measurement
/// gunnar never took. The page needs the absence to tell *nothing moved*
/// from *this could not be read*.
///
/// Seen red on the applied JSON with the rows sorted newest-first —
/// *"day 0 is the wrong day: left Some(1788393600000) right
/// Some(1787961600000)"* — and with a synthetic zero row inserted for the
/// quiet day, *"3 rows became 6; a day gunnar never wrote is not a day with
/// no traffic"*.
#[tokio::test]
async fn the_days_come_back_oldest_first_exactly_as_gunnar_sent_them() {
let app = console(with_ledger(vec!["alpha"])).await;
let (status, body) = get(&app, "/api/namespaces/alpha/activity").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
assert_eq!(seen["namespace"], "alpha", "{body}");
assert_eq!(
seen["retained_days"].as_i64(),
Some(400),
"the window gunnar keeps is what tells an empty answer from an unanswerable \
one: {body}"
);
let days = seen["days"].as_array().expect("an array of days");
assert_eq!(
days.len(),
3,
"{} rows became {}; a day gunnar never wrote is not a day with no traffic: {body}",
ledger().len(),
days.len()
);
for (i, sent) in ledger().into_iter().enumerate() {
let got = &days[i];
assert_eq!(
got["day_unix_ms"].as_i64(),
Some(sent.day_unix_ms),
"day {i} is the wrong day: {body}"
);
for (name, value) in [
("bytes_in", sent.bytes_in),
("bytes_out", sent.bytes_out),
("pushes", sent.pushes),
("fetches", sent.fetches),
("lfs_bytes_in", sent.lfs_bytes_in),
("lfs_bytes_out", sent.lfs_bytes_out),
("refused", sent.refused),
("auth_failures", sent.auth_failures),
("distinct_principals", sent.distinct_principals),
] {
assert_eq!(
got[name].as_u64(),
Some(value),
"day {i} {name} is not the number gunnar counted: {body}"
);
}
assert_eq!(
got["distinct_principals_capped"].as_bool(),
Some(sent.distinct_principals_capped),
"day {i}: a floor that stopped growing must say so: {body}"
);
}
// The gap, named: the day between the first two rows has no row, and
// nothing in the answer claims otherwise.
assert!(
!days
.iter()
.any(|d| d["day_unix_ms"].as_i64() == Some(D0 + DAY)),
"a day with no traffic was invented as a row: {body}"
);
}
/// ★ **A refusal reason gunnar did not send is ABSENT, never 0.**
///
/// The fixture's walled day was refused by `admission` and by `cap`, and
/// never by `policy`. Writing `"policy": 0` out would say *nothing was
/// refused by the push gate today*, which this console did not measure and
/// gunnar did not claim — and the reader who most needs the difference is
/// the tenant deciding whether their problem is money or a protected
/// branch.
///
/// Seen red against a render that pre-seeded the three known reasons:
/// *"the console invented a reason gunnar did not send: policy"*.
#[tokio::test]
async fn a_refusal_reason_gunnar_did_not_send_is_absent_rather_than_zero() {
let app = console(with_ledger(vec!["alpha"])).await;
let (status, body) = get(&app, "/api/namespaces/alpha/activity").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
let walled = &seen["days"][1];
assert_eq!(
walled["refused_by_reason"]["admission"].as_u64(),
Some(1),
"{body}"
);
assert_eq!(
walled["refused_by_reason"]["cap"].as_u64(),
Some(4),
"{body}"
);
assert!(
walled["refused_by_reason"].get("policy").is_none(),
"the console invented a reason gunnar did not send: policy — {body}"
);
// And the total is gunnar's own, not the sum of the breakdown: a reason
// gunnar has not learned to name yet would make the two disagree, and
// the number a tenant reads must be the one the server counted.
assert_eq!(walled["refused"].as_u64(), Some(5), "{body}");
// A day nothing was refused on carries an EMPTY map, not the previous
// day's keys and not three zeroes.
assert_eq!(
seen["days"][0]["refused_by_reason"]
.as_object()
.map(|m| m.len()),
Some(0),
"a day with no refusals must carry no reasons at all: {body}"
);
}
/// ★ **A day nobody pushed on renders `null`, not an epoch date.**
///
/// The third row is fetch-only, so gunnar's `last_push_unix_ms` for it is a
/// genuine 0. Carried as a number it would be drawn as 1 Jan 1970 — or,
/// on this console, as "689 months ago" beside a bar for last Tuesday.
///
/// Seen red with the stamp carried raw instead of through
/// [`crate::api::recorded`]: *"a day with no push is dated 1970:
/// Number(0)"*.
#[tokio::test]
async fn a_day_with_no_push_renders_null_and_not_an_epoch_date() {
let app = console(with_ledger(vec!["alpha"])).await;
let (status, body) = get(&app, "/api/namespaces/alpha/activity").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
let fetch_only = &seen["days"][2];
assert!(
fetch_only["last_push_unix_ms"].is_null(),
"a day with no push is dated 1970: {:?} — {body}",
fetch_only["last_push_unix_ms"]
);
// The field is PRESENT and null rather than missing: the browser tells
// "no push that day" from "this build does not report it" by the field
// being there.
assert!(
fetch_only.get("last_push_unix_ms").is_some(),
"the field must be present and null: {body}"
);
// …and the fetch of that same day, which really happened, survives —
// or the rule above would be "always null".
assert_eq!(
fetch_only["last_fetch_unix_ms"].as_i64(),
Some(D0 + 5 * DAY + 71_000_000),
"{body}"
);
// The count beside it is a real zero and stays one: nobody pushed is a
// measurement, unlike a stamp that was never written.
assert_eq!(fetch_only["pushes"].as_u64(), Some(0), "{body}");
}
/// ★ **A namespace the caller may not read yields no days.**
///
/// `Usage.Activity` takes `Read` on the namespace, the same grant
/// `Usage.Read` takes, and the console invents no second rule: gunnar's
/// `PermissionDenied` is relayed as a 403 in gunnar's own words carrying
/// not one byte of `beta`'s traffic.
///
/// The control comes first, as it does for `/usage`: without it this would
/// pass against a console that refused everything, which is not the feature.
#[tokio::test]
async fn a_namespaces_activity_is_refused_and_leaks_no_days() {
let fake = FakeGunnar {
signed_by: Default::default(),
// `beta` is counted, and its ledger is here to be leaked. It is
// simply not this caller's.
days: BTreeMap::from([("alpha", ledger()), ("beta", ledger())]),
..FakeGunnar::counting(
vec!["alpha"],
BTreeMap::from([("alpha", meters("alpha")), ("beta", meters("beta"))]),
)
};
let app = console(fake).await;
let (allowed, body) = get(&app, "/api/namespaces/alpha/activity").await;
assert_eq!(
allowed,
http::StatusCode::OK,
"the caller's own namespace must still be readable, or this proves nothing: {body}"
);
let (status, body) = get(&app, "/api/namespaces/beta/activity").await;
// As for `/usage`: refused reads the same as absent (`api::absent`).
assert_eq!(
status,
http::StatusCode::NOT_FOUND,
"a refused namespace answered {status}: {body}"
);
assert_eq!(body, crate::api::absent::body().to_string());
assert!(
!body.contains("\"days\""),
"the refusal carries a days array: {body}"
);
// The numbers themselves, as they would be written in JSON.
for day in ledger() {
for value in [day.bytes_in, day.bytes_out, day.fetches] {
if value == 0 {
// 0 appears in an HTTP status and proves nothing.
continue;
}
assert!(
!body.contains(&value.to_string()),
"the refusal carries beta's traffic ({value}): {body}"
);
}
}
}
/// **An empty series is not a refusal, and it says how far back it looked.**
///
/// `days: []` with a non-zero `retained_days` is *nothing moved*. That is a
/// 200 with an answer in it, and the page renders it as a quiet tenant. A
/// console that turned it into an error — or into the same blank the
/// refusal draws — would make "silent" and "not allowed to look"
/// indistinguishable, which is the pair this whole route exists to tell
/// apart.
#[tokio::test]
async fn a_namespace_that_moved_nothing_answers_with_no_days_and_not_an_error() {
let app = console(FakeGunnar::counting(
vec!["alpha"],
BTreeMap::from([("alpha", meters("alpha"))]),
))
.await;
let (status, body) = get(&app, "/api/namespaces/alpha/activity").await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let seen: serde_json::Value = serde_json::from_str(&body).expect("JSON");
assert_eq!(
seen["days"].as_array().map(Vec::len),
Some(0),
"a quiet namespace must answer with an empty series: {body}"
);
assert_eq!(
seen["retained_days"].as_i64(),
Some(400),
"…and still say how far back the box remembers, or the page cannot tell \
\"nothing moved\" from \"you asked about a time this box forgot\": {body}"
);
assert_eq!(seen["namespace"], "alpha", "{body}");
}
/// **Thirty WHOLE UTC days, ending with today.**
///
/// The window is floored to midnight rather than measured back from the
/// clock: gunnar's rows are whole UTC days, so a `since` at 14:00 would
/// make the oldest bar a part-day drawn at full height beside twenty-nine
/// whole ones. And it is `days − 1` back, because the window is inclusive
/// at both ends — a bare `days` is a thirty-first bar.
///
/// The fake asserts the same three properties against the live request (see
/// `FakeGunnar::activity`); this pins the arithmetic in one line so a
/// failure there says which half is wrong.
#[test]
fn the_window_is_thirty_whole_utc_days_ending_with_today() {
// Midday of a day, and midnight of the same day, must produce the same
// window: the clock's time of day may not move the x axis.
let midnight = 20_694 * DAY;
let midday = midnight + 14 * 3_600_000;
assert_eq!(
window_start(midnight, ACTIVITY_DAYS),
window_start(midday, ACTIVITY_DAYS)
);
// Inclusive at both ends: today plus the twenty-nine before it.
assert_eq!(window_start(midday, ACTIVITY_DAYS), midnight - 29 * DAY);
assert_eq!(window_start(midday, 1), midnight, "one day is today alone");
// It IS a midnight, which is what the fake checks on the wire.
assert_eq!(window_start(midday, ACTIVITY_DAYS) % DAY, 0);
// A millisecond before the epoch floors to the day it is IN, not the
// one after: `/` rounds towards zero and would be off by a whole day.
assert_eq!(window_start(-1, 1), -DAY);
assert_eq!(
ACTIVITY_DAYS, 30,
"the page says \"the last 30 days\" in words"
);
}
/// The day mapping itself, away from the wire — the timestamp rule and the
/// reason map in one line each, so the end-to-end rows above say WHICH half
/// broke when they go red.
#[test]
fn a_day_row_is_carried_field_for_field() {
let sent = ledger().remove(1);
let got = render_day(sent.clone());
assert_eq!(got.day_unix_ms, sent.day_unix_ms);
assert_eq!(got.bytes_in, sent.bytes_in);
assert_eq!(got.bytes_out, sent.bytes_out);
assert_eq!(got.lfs_bytes_in, sent.lfs_bytes_in);
assert_eq!(got.lfs_bytes_out, sent.lfs_bytes_out);
assert_eq!(got.refused, sent.refused);
assert_eq!(got.auth_failures, sent.auth_failures);
assert_eq!(got.distinct_principals, sent.distinct_principals);
assert_eq!(
got.refused_by_reason,
BTreeMap::from([("admission".to_owned(), 1u64), ("cap".to_owned(), 4u64)]),
"every key gunnar sent, and no key it did not"
);
assert_eq!(got.last_push_unix_ms, Some(sent.last_push_unix_ms));
// The zero rule, on a row of its own.
let quiet = render_day(pb::DayActivity {
last_push_unix_ms: 0,
last_fetch_unix_ms: 0,
..ledger().remove(0)
});
assert_eq!(quiet.last_push_unix_ms, None);
assert_eq!(quiet.last_fetch_unix_ms, None);
}
/// The growth word: the two verdicts the page renders, and everything
/// else is `unknown` — including UNSPECIFIED, which is a field nobody
/// filled and must not read as a promise.
#[test]
fn the_growth_word_names_sealed_and_operator_only_and_nothing_else() {
assert_eq!(growth_word(pb::SetGrowth::GrowthSealed as i32), "sealed");
assert_eq!(
growth_word(pb::SetGrowth::GrowthOperatorOnly as i32),
"operator_only"
);
assert_eq!(growth_word(pb::SetGrowth::GrowthUnknown as i32), "unknown");
assert_eq!(growth_word(pb::SetGrowth::Unspecified as i32), "unknown");
assert_eq!(growth_word(99), "unknown");
}
// ── Whose key answers a namespace's meters ─────────────────────────────
//
// `serve.rs` says of this route: *"reading your own meters is not an act of
// administration, and gunnar's own grant on the namespace is what decides
// whether the call is answered at all (see `api::usage`)."*
//
// That described the intent and, until this test, not the code. Every other
// read surface in this crate takes `api::Reader` — nine modules do — and
// `api::usage` was the one that did not: it signed with `state.gunnar`, the
// console's SERVER-WIDE ADMIN key. gunnar can only refuse a caller it can
// narrow, and that caller could never be narrowed, so the grant the comment
// relies on was never consulted.
//
// In practice: `role_floor` puts a GET at `Viewer`, the namespace comes
// from the path, and the credential was an admin's — so a signed-in viewer
// could read any namespace's `pack_bytes`, its caps and its day-by-day
// activity by spelling the name. How much every other customer stores, and
// what they pay for.
/// A console with a real passkey subsystem, so a viewer session is the real
/// thing and `Reader` runs its real ladder instead of the open-on-loopback
/// bypass.
async fn gated(
fake: FakeGunnar,
) -> (
axum::Router,
Arc<crate::auth::Webauthn>,
String,
crate::state::StateStore,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(UsageServer::new(fake.clone()))
.add_service(AdminServer::new(fake))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let dir = tempfile::tempdir().expect("a temp dir").keep();
let store = crate::state::StateStore::open(dir.join("state.redb")).expect("a store");
let webauthn = Arc::new(
crate::auth::Webauthn::new(
crate::auth::RelyingParty {
rp_id: "console.example".to_owned(),
origin: "https://console.example".to_owned(),
rp_name: "gunnar".to_owned(),
},
store.clone(),
)
.expect("a subsystem"),
);
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[7u8; 32]),
))
.expect("a fixed seed makes a signer"),
);
let console_fingerprint = signer.fingerprint().to_owned();
let endpoint = format!("http://{addr}");
let router = crate::serve::router(Arc::new(crate::app::App {
webauthn: Some(webauthn.clone()),
admin_token: None,
#[cfg(feature = "metered")]
monetize: None,
events: crate::sse::EventHub::detached(),
gunnar: crate::grpc::Gunnar::connect_lazy(&endpoint, signer).expect("a lazy handle"),
twin: None,
guest: None,
reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
endpoint.clone(),
store.clone(),
)),
tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store.clone())),
forges: Default::default(),
imports: Default::default(),
forge_apps: Default::default(),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
}));
(router, webauthn, console_fingerprint, store)
}
/// ★ **A viewer's read of somebody else's namespace is not signed with the
/// console's admin key.**
///
/// Asserted on the FINGERPRINT in the `gunnar-sshsig-v2` credential,
/// because that is the only thing that can tell the console's key from the
/// tenant's from outside this process — and because asserting on the HTTP
/// status would not have caught this at all: with an admin credential
/// gunnar answers 200, so the route looked like it worked.
#[tokio::test]
async fn a_viewers_read_is_not_signed_with_the_consoles_admin_key() {
let fake = FakeGunnar::counting(
// The fake would happily serve `beta` to an admin, which is the
// point: the refusal must come from the CREDENTIAL being narrow,
// not from this list.
vec!["alice", "beta"],
BTreeMap::from([
("alice", pb::NamespaceUsage::default()),
("beta", pb::NamespaceUsage::default()),
]),
);
let signed_by = fake.signed_by.clone();
let (router, webauthn, console_fingerprint, store) = gated(fake).await;
// **vera's identity-core session** — what a passkey login through the
// core leaves (IDENTITY-HOST). This fake gunnar serves only `Usage`
// and `Admin`, so no login can run against it; the session is planted
// as the core would have said it, and her reads carry its token.
let _ = &store;
let session = webauthn.plant_core_session_for_tests("vera", crate::role::Role::Viewer);
let fingerprint = format!("session:{session}");
let response = router
.oneshot(
http::Request::builder()
.uri("/api/namespaces/beta/usage")
.header(
http::header::COOKIE,
format!("{}={session}", crate::auth::SESSION_COOKIE),
)
.body(axum::body::Body::empty())
.expect("a request"),
)
.await
.expect("the router answers");
let _ = response.status();
let signed = signed_by.lock().expect("the fingerprint log").clone();
assert!(
!signed.is_empty(),
"the read never reached gunnar, so this test proves nothing"
);
assert!(
signed.contains(&fingerprint),
"the read did not carry the person's own session"
);
assert!(
!signed.contains(&console_fingerprint),
"a viewer's read of another namespace was signed with the CONSOLE's server-wide \
admin key ({console_fingerprint}). gunnar cannot refuse a caller it cannot narrow, \
so every namespace on the box is readable by anyone who can spell its name."
);
}
}