use std::sync::Arc;
use gunnar_core::{NewRepo, RepoRef};
use gunnar_grpc::seam::DeleteOptions;
use gunnar_grpc::ControlPlane as _;
use gunnar_grpc::{Caller, EventBus, ServerConfig, ServiceContext, UsageService};
use gunnar_lfs::LfsStore as _;
use gunnar_proto::usage_server::Usage as _;
use gunnar_proto::{Allowance, NamespaceUsage, ReadUsageRequest, ReadUsageResponse, RepoPath};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::{dir_bytes, ServerStores};
use gunnar_store::conformance::pack::PackBuilder;
use prost::Message as _;
fn seed(stores: &ServerStores, path: &RepoRef, blobs: usize, size: usize) -> std::path::PathBuf {
let repo = stores
.create_repo(NewRepo::new(path.clone()), false)
.expect("create");
let handle = stores.repo_store(&repo).expect("open");
let mut pb = PackBuilder::new(gix_hash::Kind::Sha256);
for i in 0..blobs {
let mut body = vec![b'a' + (i % 26) as u8; size];
body[0] = i as u8;
pb.object(gix_object::Kind::Blob, body);
}
let fixture = pb.build().expect("pack");
handle.serve().put_pack(&fixture.bytes).expect("put_pack");
let git_dir = handle.git_dir().to_path_buf();
drop(handle);
stores.close_repo(path);
git_dir
}
fn expected_pack_bytes(git_dir: &std::path::Path) -> u64 {
dir_bytes(git_dir) - lfs_bytes(git_dir) - exploded_bytes(git_dir)
}
fn lfs_bytes(git_dir: &std::path::Path) -> u64 {
dir_bytes(&git_dir.join("lfs"))
}
fn exploded_bytes(git_dir: &std::path::Path) -> u64 {
let store = git_dir.join("gunnar-git.znippy");
let Ok(entries) = std::fs::read_dir(&store) else {
return 0;
};
entries
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("objects.exploded")
})
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum()
}
fn row<'a>(rows: &'a [NamespaceUsage], ns: &str) -> &'a NamespaceUsage {
rows.iter()
.find(|r| r.namespace == ns)
.unwrap_or_else(|| panic!("no usage row for {ns}: {rows:?}"))
}
#[test]
fn meters_are_summed_per_namespace_and_a_soft_delete_moves_bytes_to_tombstoned() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
for ns in ["alpha", "beta", "empty"] {
stores.catalog().create_namespace(ns).expect("namespace");
}
let a1 = RepoRef::new("alpha", "one").expect("legal");
let a2 = RepoRef::new("alpha", "two").expect("legal");
let b1 = RepoRef::new("beta", "solo").expect("legal");
let a1_dir = seed(&stores, &a1, 3, 20_000);
let a2_dir = seed(&stores, &a2, 5, 40_000);
let b1_dir = seed(&stores, &b1, 2, 70_000);
let lfs = gunnar_lfs::FsLfsStore::open_or_init(&a2_dir).expect("lfs store");
let lfs_body = vec![0x4cu8; 123_456];
let oid = gunnar_lfs::Oid::of(&lfs_body);
{
use std::io::Write as _;
let mut w = lfs.begin_write(&oid, lfs_body.len() as u64).expect("begin");
w.write_all(&lfs_body).expect("write");
w.commit().expect("commit");
}
assert_eq!(
lfs_bytes(&a2_dir),
123_456,
"the fixture's LFS object is on disk"
);
let plane = CoreControlPlane::new(Arc::clone(&stores));
let all = plane.usage(None).expect("Usage.ReadAll");
assert_eq!(
all.len(),
3,
"every catalogued namespace is a row, the empty one included: {all:?}"
);
let alpha = row(&all, "alpha");
let beta = row(&all, "beta");
let empty = row(&all, "empty");
assert_eq!(alpha.repos, 2);
assert_eq!(beta.repos, 1);
assert_eq!(empty.repos, 0);
assert_eq!(
alpha.pack_bytes,
expected_pack_bytes(&a1_dir) + expected_pack_bytes(&a2_dir),
"alpha's pack bytes are its two repositories' and nothing else"
);
assert_eq!(beta.pack_bytes, expected_pack_bytes(&b1_dir));
assert!(
beta.pack_bytes > 2 * 70_000,
"a 140 kB push cannot weigh {} bytes on disk — the meter is not reading the pack",
beta.pack_bytes
);
assert_ne!(
alpha.pack_bytes, beta.pack_bytes,
"two namespaces with different pushes must not report one number"
);
assert_eq!(empty.pack_bytes, 0);
assert_eq!(alpha.lfs_bytes, 123_456);
assert_eq!(beta.lfs_bytes, 0);
assert!(
alpha.pack_bytes < dir_bytes(&a1_dir) + dir_bytes(&a2_dir),
"pack_bytes must exclude the LFS object: {} is the whole directory",
alpha.pack_bytes
);
assert_eq!(alpha.tombstoned_bytes, 0);
assert_eq!(beta.tombstoned_bytes, 0);
assert_eq!(
alpha.objects, 0,
"closed stores hold no indexed objects in RAM"
);
assert_eq!(
alpha.cold_bytes, 0,
"one archive per repository: no cold tier to report"
);
assert!(alpha.measured_at_unix_ms > 0);
let one = plane.usage(Some("alpha")).expect("Usage.Read");
assert_eq!(one.len(), 1);
assert_eq!(one[0].pack_bytes, alpha.pack_bytes);
assert_eq!(one[0].lfs_bytes, alpha.lfs_bytes);
let missing = plane
.usage(Some("nobody"))
.expect_err("an unknown namespace is refused");
assert!(
matches!(missing, gunnar_grpc::ControlError::NotFound(_)),
"not NotFound: {missing:?}"
);
let repo = stores.catalog().get(&b1).expect("read").expect("row");
let held = stores.repo_store(&repo).expect("open");
held.serve().refs().expect("touch the store");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let open = loop {
let open = plane.usage(Some("beta")).expect("Usage.Read");
if open[0].objects == 2 || std::time::Instant::now() > deadline {
break open;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
assert_eq!(
open[0].objects, 2,
"beta/solo is open and holds two indexed objects: {:?}",
open[0]
);
let alpha_still_closed = plane.usage(Some("alpha")).expect("Usage.Read");
assert_eq!(
alpha_still_closed[0].objects, 0,
"opening beta must not charge alpha"
);
drop(held);
stores.close_repo(&b1);
let bytes_of_a1 = dir_bytes(&a1_dir);
plane
.delete_repo(
&RepoPath::new("alpha", "one"),
DeleteOptions {
purge: false,
retention_days: Some(30),
principal_id: "tester".into(),
},
)
.expect("soft delete");
let after = plane.usage(Some("alpha")).expect("Usage.Read");
let alpha_after = &after[0];
assert_eq!(
alpha_after.repos, 1,
"the tombstoned repository is no longer live"
);
assert_eq!(
alpha_after.pack_bytes,
expected_pack_bytes(&a2_dir),
"the deleted repository's pack bytes left pack_bytes"
);
assert_eq!(
alpha_after.tombstoned_bytes, bytes_of_a1,
"…and every byte its directory still holds is in tombstoned_bytes"
);
assert!(
alpha_after.tombstoned_bytes > 0,
"the tombstone holds real bytes"
);
let beta_after = plane.usage(Some("beta")).expect("Usage.Read");
assert_eq!(
beta_after[0].tombstoned_bytes, 0,
"alpha's tombstone is not charged to beta"
);
}
#[test]
fn lfs_bytes_is_the_store_itself_and_charges_uploads_in_flight() {
use std::io::Write as _;
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("team")
.expect("namespace");
let path = RepoRef::new("team", "one").expect("legal");
let git_dir = seed(&stores, &path, 3, 20_000);
let plane = CoreControlPlane::new(Arc::clone(&stores));
let packed = plane.usage(Some("team")).expect("usage")[0].pack_bytes;
assert!(packed > 0, "the fixture pushed a pack");
let read = |plane: &CoreControlPlane| {
let row = plane
.usage(Some("team"))
.expect("usage")
.pop()
.expect("row");
(row.pack_bytes, row.lfs_bytes)
};
assert_eq!(read(&plane), (packed, 0), "no LFS yet");
let lfs = gunnar_lfs::FsLfsStore::open_or_init(&git_dir).expect("lfs store");
let committed = vec![0x4cu8; 123_456];
let oid = gunnar_lfs::Oid::of(&committed);
{
let mut w = lfs
.begin_write(&oid, committed.len() as u64)
.expect("begin");
w.write_all(&committed).expect("write");
w.commit().expect("commit");
}
assert_eq!(
lfs.size_of(&oid)
.expect("stat")
.expect("the object is there"),
123_456
);
assert_eq!(
read(&plane),
(packed, 123_456),
"the meter is the LFS store's bytes, and they are not in pack_bytes"
);
let in_flight = vec![0x5du8; 100_000];
let pending_oid = gunnar_lfs::Oid::of(&in_flight);
let mut pending = lfs
.begin_write(&pending_oid, in_flight.len() as u64)
.expect("begin");
pending.write_all(&in_flight).expect("write");
pending.flush().expect("flush");
assert_eq!(
lfs.size_of(&pending_oid).expect("stat"),
None,
"an uncommitted upload resolves to no oid — that is the store's whole safety property"
);
assert_eq!(
read(&plane),
(packed, 223_456),
"an upload in flight is on the tenant's disk and on their bill"
);
drop(pending);
assert_eq!(
read(&plane),
(packed, 123_456),
"the abandoned upload's bytes are still being charged after Drop swept the file"
);
}
fn usage_service(stores: &Arc<ServerStores>) -> UsageService {
let plane: Arc<dyn gunnar_grpc::ControlPlane> =
Arc::new(CoreControlPlane::new(Arc::clone(stores)));
UsageService::new(ServiceContext::new(
plane,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
))
}
fn read_as(
service: &UsageService,
principal: &str,
namespace: &str,
) -> Result<ReadUsageResponse, tonic::Status> {
let mut request = tonic::Request::new(ReadUsageRequest {
namespace: namespace.to_owned(),
});
request.extensions_mut().insert(Caller {
principal_id: principal.to_owned(),
fingerprint: "SHA256:test".to_owned(),
machine: false,
authenticated: true,
});
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(service.read(request))
.map(tonic::Response::into_inner)
}
fn owner(stores: &ServerStores, ns: &str) {
stores
.auth()
.add_principal(gunnar_core::Principal {
id: ns.to_owned(),
name: ns.to_owned(),
kind: gunnar_core::PrincipalKind::User,
created: 0,
})
.expect("principal");
}
#[test]
fn the_community_appliance_reports_every_cap_absent_never_zero() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
owner(&stores, "alice");
seed(
&stores,
&RepoRef::new("alice", "held").expect("legal"),
4,
30_000,
);
let service = usage_service(&stores);
let answer = read_as(&service, "alice", "alice").expect("the owner reads her own meters");
let usage = answer.usage.expect("the meters");
assert!(
usage.pack_bytes > 0,
"the fixture must hold bytes, or absence proves nothing"
);
let allowance = answer.allowance.expect(
"the allowance message itself is always present; only the caps inside are optional",
);
for (meter, cap) in [
("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),
] {
assert!(
cap.is_none(),
"{meter} cap must be ABSENT on the community appliance, not {cap:?}"
);
assert_ne!(cap, Some(0), "{meter}: absent is never spelled 0");
}
assert_eq!(
allowance,
Allowance {
enforcement: gunnar_grpc::service::meters::enforcement(),
..Allowance::default()
},
"the community appliance caps nobody and still says what its three \
doors are"
);
}
#[test]
fn a_tenant_sees_where_their_room_went_repository_by_repository() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
for ns in ["alice", "bob"] {
stores.catalog().create_namespace(ns).expect("namespace");
owner(&stores, ns);
}
let big = seed(
&stores,
&RepoRef::new("alice", "big").expect("legal"),
6,
40_000,
);
let small = seed(
&stores,
&RepoRef::new("alice", "small").expect("legal"),
2,
10_000,
);
seed(
&stores,
&RepoRef::new("bob", "theirs").expect("legal"),
3,
20_000,
);
let answer = read_as(&usage_service(&stores), "alice", "alice").expect("alice reads alice");
let usage = answer.usage.expect("meters");
let mut rows: Vec<(String, u64)> = answer
.repos
.iter()
.map(|r| {
let path = r.repo.clone().expect("a path");
(format!("{}/{}", path.namespace, path.repo), r.stored_bytes)
})
.collect();
rows.sort();
assert_eq!(
rows,
vec![
("alice/big".to_owned(), expected_pack_bytes(&big)),
("alice/small".to_owned(), expected_pack_bytes(&small)),
],
"alice's two repositories, each measured as pack_bytes is — and nothing of bob's"
);
assert_eq!(
rows.iter().map(|(_, b)| *b).sum::<u64>(),
usage.pack_bytes,
"the rows add up to the meter"
);
}
#[test]
fn the_community_appliance_has_nothing_remaining_because_nothing_is_capped() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
owner(&stores, "alice");
seed(
&stores,
&RepoRef::new("alice", "held").expect("legal"),
2,
20_000,
);
let answer = read_as(&usage_service(&stores), "alice", "alice").expect("read");
assert_eq!(
answer.remaining,
Some(gunnar_proto::Remaining::default()),
"present, and every meter absent"
);
}
#[test]
fn the_allowance_takes_read_on_the_namespace_and_a_stranger_gets_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
for ns in ["alice", "bob"] {
stores.catalog().create_namespace(ns).expect("namespace");
owner(&stores, ns);
}
let service = usage_service(&stores);
let hers = read_as(&service, "alice", "alice").expect("the owner reads");
assert!(hers.allowance.is_some());
assert!(read_as(&service, "bob", "bob")
.expect("bob reads bob")
.allowance
.is_some());
let refused = read_as(&service, "bob", "alice").expect_err("a stranger is refused");
assert_eq!(refused.code(), tonic::Code::PermissionDenied, "{refused:?}");
let err = read_as(&service, gunnar_grpc::ANONYMOUS_PRINCIPAL_ID, "alice")
.expect_err("the anonymous principal reads nobody's allowance");
assert_eq!(err.code(), tonic::Code::PermissionDenied, "{err:?}");
}
#[derive(Clone, PartialEq, prost::Message)]
struct OlderReadUsageResponse {
#[prost(message, optional, tag = "1")]
usage: Option<NamespaceUsage>,
}
#[test]
fn an_older_client_decodes_the_same_meters_from_todays_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
owner(&stores, "alice");
seed(
&stores,
&RepoRef::new("alice", "held").expect("legal"),
2,
50_000,
);
let today = read_as(&usage_service(&stores), "alice", "alice").expect("read");
assert!(
today.allowance.is_some(),
"this test needs the new field PRESENT, or it proves nothing"
);
let bytes = today.encode_to_vec();
let older = OlderReadUsageResponse::decode(bytes.as_slice()).expect("an older client decodes");
assert_eq!(older.usage, today.usage);
assert!(
older.usage.as_ref().is_some_and(|u| u.pack_bytes > 0),
"{older:?}"
);
let stripped = ReadUsageResponse {
usage: today.usage.clone(),
..ReadUsageResponse::default()
};
assert_eq!(stripped.encode_to_vec(), older.encode_to_vec());
assert!(
bytes.len() > older.encoded_len(),
"today's bytes carry more than the older message — else the allowance was absent"
);
}
#[test]
fn the_allowance_rides_on_usage_read_and_adds_no_rpc_to_the_census() {
let usage: Vec<&str> = gunnar_grpc::auth::METHODS
.iter()
.copied()
.filter(|m| m.starts_with("Usage."))
.collect();
assert_eq!(
usage,
[
"Usage.Activity",
"Usage.AdmitPlan",
"Usage.Read",
"Usage.ReadAll"
],
"the Usage family changed; the allowance was meant to ride on Usage.Read"
);
assert!(
!gunnar_grpc::auth::METHODS
.iter()
.any(|m| m.contains("Allowance")),
"no RPC is named for the allowance"
);
}