use std::collections::BTreeMap;
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, serde::Serialize, serde::Deserialize)]
pub struct TenantId(pub String);
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Enforcement {
Enforced,
Measured,
Provisioned,
}
impl Enforcement {
pub fn describe(self) -> &'static str {
match self {
Enforcement::Enforced => "the product refuses when over",
Enforcement::Measured => "counted and reported; nothing refuses",
Enforcement::Provisioned => "provisioned at the cloud provider; not policed by the product",
}
}
pub fn is_a_wall(self) -> bool {
matches!(self, Enforcement::Enforced)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Backing {
DiskBytes,
CpuMillicores,
RamBytes,
}
impl Backing {
pub fn unit(self) -> &'static str {
match self {
Backing::DiskBytes => "disk_gib_month",
Backing::CpuMillicores => "cpu_core_month",
Backing::RamBytes => "ram_gib_month",
}
}
pub const ALL: [Backing; 3] = [Backing::DiskBytes, Backing::CpuMillicores, Backing::RamBytes];
pub fn from_unit(unit: &str) -> Option<Backing> {
Backing::ALL.into_iter().find(|b| b.unit() == unit)
}
pub fn price_month(self, delta: u64, minor_per_unit_month: u64) -> u64 {
const GIB: u64 = 1 << 30;
match self {
Backing::DiskBytes | Backing::RamBytes => delta.div_ceil(GIB).saturating_mul(minor_per_unit_month),
Backing::CpuMillicores => u64::try_from(u128::from(delta) * u128::from(minor_per_unit_month) / 1000).unwrap_or(u64::MAX),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Scope {
Tenant,
Product,
}
impl Scope {
pub fn sums_across_tenants(self) -> bool {
matches!(self, Scope::Tenant)
}
pub fn name(self) -> &'static str {
match self {
Scope::Tenant => "tenant",
Scope::Product => "product",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Meter {
pub name: &'static str,
pub meaning: &'static str,
pub enforcement: Enforcement,
pub reports_usage: bool,
pub backing: Option<Backing>,
pub scope: Scope,
}
impl Meter {
pub const fn new(name: &'static str, meaning: &'static str, enforcement: Enforcement) -> Meter {
Meter { name, meaning, enforcement, reports_usage: true, backing: None, scope: Scope::Tenant }
}
pub const fn cap_only(name: &'static str, meaning: &'static str, enforcement: Enforcement) -> Meter {
Meter { name, meaning, enforcement, reports_usage: false, backing: None, scope: Scope::Tenant }
}
pub const fn with_backing(self, backing: Backing) -> Meter {
Meter { backing: Some(backing), ..self }
}
pub const fn per_product(self) -> Meter {
Meter { scope: Scope::Product, ..self }
}
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Growth {
Sealed(String),
OperatorOnly(String),
Unknown(String),
}
impl Growth {
pub fn name(&self) -> &'static str {
match self {
Growth::Sealed(_) => "sealed",
Growth::OperatorOnly(_) => "operator_only",
Growth::Unknown(_) => "unknown",
}
}
pub fn detail(&self) -> &str {
match self {
Growth::Sealed(s) | Growth::OperatorOnly(s) | Growth::Unknown(s) => s,
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Servable {
Measured {
servable_bytes: u64,
total_bytes: u64,
growth: Growth,
ceilings: BTreeMap<String, u64>,
},
Unmeasured(String),
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Usage {
pub meters: BTreeMap<String, u64>,
pub measured_at_unix_ms: u64,
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntitlementFact {
pub tenant: TenantId,
pub plan: String,
pub state: State,
pub paid_until_unix_ms: Option<u64>,
pub caps: BTreeMap<String, u64>,
pub source: String,
pub signature: Vec<u8>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum State {
Free,
Paid,
Grace,
Suspended,
Retention,
}
#[derive(Clone, Debug)]
pub enum ProductError {
Refused(String),
Unavailable(String),
}
impl std::fmt::Display for ProductError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProductError::Refused(r) => write!(f, "product refused: {r}"),
ProductError::Unavailable(r) => write!(f, "product unavailable: {r}"),
}
}
}
impl std::error::Error for ProductError {}
pub trait Product: Send + Sync {
fn id(&self) -> &'static str;
fn meters(&self) -> &[Meter];
fn list_tenants(&self) -> Result<Vec<TenantId>, ProductError>;
fn read_usage(&self, tenant: &TenantId) -> Result<Usage, ProductError>;
fn push_entitlement(&self, fact: &EntitlementFact) -> Result<(), ProductError>;
fn can_absorb(&self, tenant: &TenantId, caps: &BTreeMap<String, u64>) -> Result<(), String>;
fn servable(&self) -> Servable;
fn purge_tenant(&self, tenant: &TenantId, reason: &str) -> Result<Purged, ProductError> {
let _ = (tenant, reason);
Err(ProductError::Refused(format!(
"the {} plugin cannot purge a tenant's data: it has no purge verb, so nothing here \
can promise the tenant's bytes are gone. Empty the tenant in the product itself \
before releasing its resources.",
self.id()
)))
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Purged {
pub tenant: TenantId,
pub detail: String,
pub bytes_reclaimed: Option<u64>,
pub still_locked: bool,
pub failures: Vec<String>,
}
pub fn bytes(n: u64) -> String {
const KIB: u64 = 1 << 10;
const MIB: u64 = 1 << 20;
const GIB: u64 = 1 << 30;
const TIB: u64 = 1 << 40;
let (unit, per) = match n {
n if n >= TIB => ("TiB", TIB),
n if n >= GIB => ("GiB", GIB),
n if n >= MIB => ("MiB", MIB),
n if n >= KIB => ("KiB", KIB),
n => return format!("{n} B"),
};
format!("{}.{} {unit}", n / per, ((n % per) * 10) / per)
}
#[cfg(test)]
mod backing_tests {
use super::Backing;
#[test]
fn prices_round_bytes_up_to_the_unit_and_cpu_by_the_millicore() {
const GIB: u64 = 1 << 30;
assert_eq!(Backing::DiskBytes.price_month(90 * GIB, 2200), 198_000, "90 GiB × 22.00 SEK");
assert_eq!(Backing::DiskBytes.price_month(90 * GIB + 1, 2200), 200_200, "one byte over is the 91st GiB");
assert_eq!(Backing::DiskBytes.price_month(0, 2200), 0);
assert_eq!(Backing::RamBytes.price_month(3 * GIB, 700), 2100);
assert_eq!(Backing::CpuMillicores.price_month(1500, 30_000), 45_000, "1.5 cores at 300.00");
assert_eq!(Backing::CpuMillicores.price_month(1, 30_000), 30, "one millicore is not free and not a core");
for b in Backing::ALL {
assert_eq!(b.price_month(u64::MAX / 4, 0), 0, "{b:?}");
}
for b in Backing::ALL {
assert_eq!(Backing::from_unit(b.unit()), Some(b));
}
assert_eq!(Backing::from_unit("moon_month"), None);
}
}
#[cfg(test)]
mod bytes_tests {
use super::bytes;
#[test]
fn only_a_genuine_zero_reads_as_zero() {
for n in [1u64, 512, 1 << 20, 67_108_864, (1 << 30) - 1] {
let s = bytes(n);
assert!(!s.starts_with("0.0 ") && !s.starts_with("0 "), "{n} bytes rendered as {s:?}, which reads as nothing");
}
assert_eq!(bytes(0), "0 B");
}
#[test]
fn the_unit_scales_and_the_value_truncates() {
assert_eq!(bytes(999), "999 B");
assert_eq!(bytes(1536), "1.5 KiB");
assert_eq!(bytes(67_108_864), "64.0 MiB");
assert_eq!(bytes((1 << 30) - 1), "1023.9 MiB", "truncates, never rounds up past the cap");
assert_eq!(bytes(10 << 30), "10.0 GiB");
assert_eq!(bytes(3 << 40), "3.0 TiB");
assert_eq!(bytes(u64::MAX), "16777215.9 TiB", "no overflow at the top of the range");
}
}