use std::collections::BTreeMap;
pub mod flow;
#[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),
AtRestart(String),
AtRuntime(String),
}
impl Growth {
pub fn name(&self) -> &'static str {
match self {
Growth::Sealed(_) => "sealed",
Growth::OperatorOnly(_) => "operator_only",
Growth::Unknown(_) => "unknown",
Growth::AtRestart(_) => "at_restart",
Growth::AtRuntime(_) => "at_runtime",
}
}
pub fn detail(&self) -> &str {
match self {
Growth::Sealed(s) | Growth::OperatorOnly(s) | Growth::Unknown(s) | Growth::AtRestart(s) | Growth::AtRuntime(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, Default, serde::Serialize, serde::Deserialize)]
pub struct ActivityDay {
pub day_unix_ms: u64,
pub meters: BTreeMap<String, u64>,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Activity {
pub days: Vec<ActivityDay>,
pub retained_days: 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 {}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Absorb {
OnHand,
WithIron { note: String },
}
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 read_activity(
&self,
tenant: &TenantId,
since_unix_ms: u64,
until_unix_ms: u64,
) -> Result<Activity, ProductError> {
let _ = (tenant, since_unix_ms, until_unix_ms);
Err(ProductError::Refused(format!(
"the {} plugin cannot report a tenant's activity: it has no flow series, so an empty answer here would read as a customer who had gone quiet rather than as a question nobody asked the product.",
self.id()
)))
}
fn push_entitlement(&self, fact: &EntitlementFact) -> Result<(), ProductError>;
fn can_absorb(&self, tenant: &TenantId, caps: &BTreeMap<String, u64>) -> Result<Absorb, 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()
)))
}
fn grow(&self) -> Option<&dyn ApplianceGrow> {
None
}
fn twins(&self) -> Result<Vec<TwinFill>, ProductError> {
Ok(Vec::new())
}
fn tenant_renewal(&self) -> bool {
false
}
fn may_act_for(
&self,
_tenant: &TenantId,
_purpose: &str,
_caps: &std::collections::BTreeMap<String, u64>,
_ticket: &[u8],
) -> Result<ActorVerdict, ProductError> {
Ok(ActorVerdict::Refused("this product cannot verify a tenant-presented ticket".into()))
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ActorVerdict {
Allowed,
Refused(String),
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct TwinFill {
pub principal: String,
pub address: String,
pub state: String,
pub last_seen_unix_ms: i64,
pub lag_entries: u64,
pub disk_total_bytes: u64,
pub disk_used_bytes: u64,
pub fill_permille: u32,
}
impl TwinFill {
pub fn visible(&self) -> bool {
matches!(self.state.as_str(), "ok" | "behind")
}
}
pub trait ApplianceGrow: Send + Sync {
fn flush(&self, timeout_secs: u32) -> Result<FlushReport, ProductError>;
fn start(&self, target_sectors_per_member: u64, go_ahead: &[u8]) -> Result<GrowStatus, ProductError>;
fn status(&self) -> Result<GrowStatus, ProductError>;
fn resume(&self, give_up: bool) -> Result<GrowStatus, ProductError>;
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GrowPhase {
Idle,
Draining,
Unmounted,
WaitingMembers,
Growing,
Mounting,
Done,
Failed,
Other,
}
impl GrowPhase {
pub fn parse(word: &str) -> GrowPhase {
match word {
"idle" => GrowPhase::Idle,
"draining" => GrowPhase::Draining,
"unmounted" => GrowPhase::Unmounted,
"waiting-members" => GrowPhase::WaitingMembers,
"growing" => GrowPhase::Growing,
"mounting" => GrowPhase::Mounting,
"done" => GrowPhase::Done,
"failed" => GrowPhase::Failed,
_ => GrowPhase::Other,
}
}
pub fn volumes_free(self) -> bool {
matches!(self, GrowPhase::Unmounted | GrowPhase::WaitingMembers)
}
pub fn is_terminal(self) -> bool {
matches!(self, GrowPhase::Done | GrowPhase::Failed | GrowPhase::Idle)
}
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct GrowMember {
pub index: u32,
pub device: String,
pub disk_bytes: u64,
pub set_bytes: u64,
pub larger: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct GrowStatus {
pub phase: String,
pub why: String,
pub epoch: u64,
pub target_sectors_per_member: u64,
pub members: Vec<GrowMember>,
pub members_verdict: String,
pub engine_present: bool,
pub serving: bool,
pub in_flight: u64,
pub since_unix_ms: i64,
pub detail: String,
pub go_ahead_signer: String,
pub set_uuid: String,
pub total_bytes: u64,
}
impl GrowStatus {
pub fn phase(&self) -> GrowPhase {
GrowPhase::parse(&self.phase)
}
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct FlushReport {
pub caught_up: bool,
pub in_flight: u64,
pub pending: u64,
pub bus_gaps: u64,
pub needs_full_resync: bool,
pub last_success_unix_ms: i64,
pub flushed_at_unix_ms: i64,
pub waited_ms: u64,
pub catalog_repos: u64,
pub standbys: u64,
pub verdict: String,
}
#[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");
}
}
#[cfg(test)]
mod actor_tests {
use super::*;
struct OldPlugin;
impl Product for OldPlugin {
fn id(&self) -> &'static str {
"old"
}
fn meters(&self) -> &[Meter] {
&[]
}
fn list_tenants(&self) -> Result<Vec<TenantId>, ProductError> {
Ok(Vec::new())
}
fn read_usage(&self, _tenant: &TenantId) -> Result<Usage, ProductError> {
Err(ProductError::Refused("no".into()))
}
fn push_entitlement(&self, _fact: &EntitlementFact) -> Result<(), ProductError> {
Ok(())
}
fn can_absorb(&self, _tenant: &TenantId, _caps: &BTreeMap<String, u64>) -> Result<Absorb, String> {
Ok(Absorb::OnHand)
}
fn servable(&self) -> Servable {
Servable::Unmeasured("a test double measures nothing".to_owned())
}
}
#[test]
fn a_product_that_never_heard_of_a_ticket_refuses_every_one() {
let p = OldPlugin;
assert!(!p.tenant_renewal(), "a console must not be told to offer self-service here");
let verdict = p.may_act_for(&TenantId("alice".into()), "renew", &Default::default(), b"anything at all").expect("the default answers rather than erroring");
let ActorVerdict::Refused(why) = verdict else { panic!("the default must DENY") };
assert!(!why.trim().is_empty(), "a refusal says why");
}
}