use std::collections::BTreeMap;
use std::fmt;
use super::canonical::{Canonical, CanonicalValue, Checksum};
use super::ids::{InvalidId, ProjectId, ResourceId, RevisionId, Slug, TenantId};
use super::record::{BodyError, PROJECT_ID_FIELD, Record, SCHEMA_FIELD, TENANT_ID_FIELD};
use super::resource::{
ResourceBody, ResourceKind, ResourceRef, ResourceScope, ResourceVersion, ResourceVersionNumber,
};
use super::revision::DesiredState;
use super::tenancy::InvalidDisplayName;
pub const POLICY_SCHEMA: &str = "axond.policy.v1";
const EPOCH_FIELD: &str = "epoch";
const BUDGET_LIMIT_FIELD: &str = "budget_limit_microdollars";
const NAMESPACE_BUDGET_LIMIT_FIELD: &str = "namespace_budget_limit_microdollars";
const RESERVATION_TTL_FIELD: &str = "reservation_ttl_seconds";
const MAX_IN_FLIGHT_FIELD: &str = "max_in_flight_per_subject";
const LEASE_TTL_FIELD: &str = "lease_ttl_seconds";
const MINIMUM_TOKEN_EPOCH_FIELD: &str = "minimum_token_epoch";
pub const BOOTSTRAP_OWNED_FIELDS: &[&str] = &[
"backend",
"create_table",
"dsn_env",
"key_prefix",
"on_unavailable",
"table",
];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PolicyError {
#[error("{reference} is a {} resource, not a {}", found.as_str(), expected.as_str())]
Kind {
reference: ResourceRef,
expected: ResourceKind,
found: ResourceKind,
},
#[error("{reference} is a blob body; a policy record is inline")]
NotInline { reference: ResourceRef },
#[error("{reference} is not a record")]
NotARecord { reference: ResourceRef },
#[error(
"{reference} declares schema `{found}`, which this build does not read (expected `{expected}`)"
)]
Schema {
reference: ResourceRef,
expected: &'static str,
found: String,
},
#[error("{reference} has no `{field}`")]
MissingField {
reference: ResourceRef,
field: &'static str,
},
#[error("{reference} carries `{field}`, which `{schema}` does not define")]
UnknownField {
reference: ResourceRef,
schema: &'static str,
field: String,
},
#[error(
"{reference} carries `{field}`, which the bootstrap file owns and a published policy may not set"
)]
BootstrapOwned {
reference: ResourceRef,
field: String,
},
#[error(
"{reference} field `{field}` is not the type `{}` defines",
POLICY_SCHEMA
)]
FieldType {
reference: ResourceRef,
field: &'static str,
},
#[error("{reference} field `{field}` is not an id: {source}")]
MalformedId {
reference: ResourceRef,
field: &'static str,
#[source]
source: InvalidId,
},
#[error("{reference} field `{field}` is out of range: {source}")]
FieldRange {
reference: ResourceRef,
field: &'static str,
#[source]
source: InvalidPolicy,
},
#[error("{reference} carries {declared}, but its resource identity is {identity}")]
IdentityMismatch {
reference: ResourceRef,
declared: String,
identity: ResourceId,
},
#[error("{reference} declares the policy of {declared}, but is scoped to {scoped:?}")]
ScopeMismatch {
reference: ResourceRef,
declared: PolicyScope,
scoped: ResourceScope,
},
}
impl PolicyError {
pub fn is_incompatible(&self) -> bool {
match self {
Self::Schema { .. } | Self::UnknownField { .. } => true,
Self::MissingField { field, .. } | Self::FieldType { field, .. } => {
*field == SCHEMA_FIELD
}
Self::FieldRange { .. } => true,
Self::Kind { .. }
| Self::NotInline { .. }
| Self::NotARecord { .. }
| Self::BootstrapOwned { .. }
| Self::MalformedId { .. }
| Self::IdentityMismatch { .. }
| Self::ScopeMismatch { .. } => false,
}
}
pub const fn reference(&self) -> ResourceRef {
match self {
Self::Kind { reference, .. }
| Self::NotInline { reference }
| Self::NotARecord { reference }
| Self::Schema { reference, .. }
| Self::MissingField { reference, .. }
| Self::UnknownField { reference, .. }
| Self::BootstrapOwned { reference, .. }
| Self::FieldType { reference, .. }
| Self::MalformedId { reference, .. }
| Self::FieldRange { reference, .. }
| Self::IdentityMismatch { reference, .. }
| Self::ScopeMismatch { reference, .. } => *reference,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidPolicy {
#[error("{value} is below the minimum of {min}")]
TooSmall { value: u64, min: u64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PolicyEpoch(u64);
impl PolicyEpoch {
pub const FIRST: Self = Self(1);
pub const fn new(value: u64) -> Result<Self, InvalidPolicy> {
if value == 0 {
return Err(InvalidPolicy::TooSmall { value, min: 1 });
}
Ok(Self(value))
}
pub const fn get(self) -> u64 {
self.0
}
pub const fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
}
impl fmt::Display for PolicyEpoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PolicyScope {
Tenant(TenantId),
Project {
tenant: TenantId,
project: ProjectId,
},
}
impl PolicyScope {
pub const fn tenant(self) -> TenantId {
match self {
Self::Tenant(tenant) | Self::Project { tenant, .. } => tenant,
}
}
pub const fn project(self) -> Option<ProjectId> {
match self {
Self::Tenant(_) => None,
Self::Project { project, .. } => Some(project),
}
}
pub const fn resource_scope(self) -> ResourceScope {
match self {
Self::Tenant(tenant) => ResourceScope::Tenant(tenant),
Self::Project { tenant, project } => ResourceScope::Project { tenant, project },
}
}
pub const fn resource_id(self) -> ResourceId {
match self {
Self::Tenant(tenant) => ResourceId::new(tenant.uuid()),
Self::Project { project, .. } => ResourceId::new(project.uuid()),
}
}
pub const fn fallback(self) -> Option<Self> {
match self {
Self::Tenant(_) => None,
Self::Project { tenant, .. } => Some(Self::Tenant(tenant)),
}
}
}
impl fmt::Display for PolicyScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Tenant(tenant) => write!(f, "the policy of tenant {tenant}"),
Self::Project { tenant, project } => {
write!(f, "the policy of project {project} in tenant {tenant}")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BudgetPolicy {
subject_limit_microdollars: u64,
namespace_limit_microdollars: Option<u64>,
reservation_ttl_seconds: u64,
}
impl BudgetPolicy {
pub const fn new(
subject_limit_microdollars: u64,
namespace_limit_microdollars: Option<u64>,
reservation_ttl_seconds: u64,
) -> Result<Self, InvalidPolicy> {
if reservation_ttl_seconds == 0 {
return Err(InvalidPolicy::TooSmall {
value: reservation_ttl_seconds,
min: 1,
});
}
Ok(Self {
subject_limit_microdollars,
namespace_limit_microdollars,
reservation_ttl_seconds,
})
}
pub const fn subject_limit_microdollars(&self) -> u64 {
self.subject_limit_microdollars
}
pub const fn namespace_limit_microdollars(&self) -> Option<u64> {
self.namespace_limit_microdollars
}
pub const fn reservation_ttl_seconds(&self) -> u64 {
self.reservation_ttl_seconds
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ConcurrencyPolicy {
max_in_flight_per_subject: u64,
lease_ttl_seconds: u64,
}
impl ConcurrencyPolicy {
pub const fn new(
max_in_flight_per_subject: u64,
lease_ttl_seconds: u64,
) -> Result<Self, InvalidPolicy> {
if max_in_flight_per_subject == 0 {
return Err(InvalidPolicy::TooSmall {
value: max_in_flight_per_subject,
min: 1,
});
}
if lease_ttl_seconds == 0 {
return Err(InvalidPolicy::TooSmall {
value: lease_ttl_seconds,
min: 1,
});
}
Ok(Self {
max_in_flight_per_subject,
lease_ttl_seconds,
})
}
pub const fn max_in_flight_per_subject(&self) -> u64 {
self.max_in_flight_per_subject
}
pub const fn lease_ttl_seconds(&self) -> u64 {
self.lease_ttl_seconds
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RevocationPolicy {
minimum_token_epoch: u64,
}
impl RevocationPolicy {
pub const fn new(minimum_token_epoch: u64) -> Self {
Self {
minimum_token_epoch,
}
}
pub const fn minimum_token_epoch(&self) -> u64 {
self.minimum_token_epoch
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PolicyBody {
scope: PolicyScope,
epoch: PolicyEpoch,
budget: BudgetPolicy,
concurrency: ConcurrencyPolicy,
revocation: RevocationPolicy,
}
impl PolicyBody {
pub const SCHEMA: &'static str = POLICY_SCHEMA;
const KNOWN_FIELDS: &'static [&'static str] = &[
TENANT_ID_FIELD,
PROJECT_ID_FIELD,
EPOCH_FIELD,
BUDGET_LIMIT_FIELD,
NAMESPACE_BUDGET_LIMIT_FIELD,
RESERVATION_TTL_FIELD,
MAX_IN_FLIGHT_FIELD,
LEASE_TTL_FIELD,
MINIMUM_TOKEN_EPOCH_FIELD,
];
pub const fn new(
scope: PolicyScope,
epoch: PolicyEpoch,
budget: BudgetPolicy,
concurrency: ConcurrencyPolicy,
revocation: RevocationPolicy,
) -> Self {
Self {
scope,
epoch,
budget,
concurrency,
revocation,
}
}
pub const fn scope(&self) -> PolicyScope {
self.scope
}
pub const fn epoch(&self) -> PolicyEpoch {
self.epoch
}
pub const fn budget(&self) -> &BudgetPolicy {
&self.budget
}
pub const fn concurrency(&self) -> &ConcurrencyPolicy {
&self.concurrency
}
pub const fn revocation(&self) -> &RevocationPolicy {
&self.revocation
}
pub fn generation(&self, source: RevisionId) -> PolicyGeneration {
PolicyGeneration {
scope: self.scope,
epoch: self.epoch,
source,
content: self.content(),
}
}
pub fn content(&self) -> PolicyContent {
PolicyContent::of(self)
}
pub const fn resource_id(&self) -> ResourceId {
self.scope.resource_id()
}
pub fn body(&self) -> ResourceBody {
ResourceBody::Inline(self.canonical())
}
pub fn version(&self, slug: Slug) -> ResourceVersion {
self.version_at(slug, ResourceVersionNumber::FIRST)
}
pub fn version_at(&self, slug: Slug, version: ResourceVersionNumber) -> ResourceVersion {
ResourceVersion::new(
ResourceRef::new(ResourceKind::Policy, self.resource_id(), version),
self.scope.resource_scope(),
slug,
self.body(),
)
}
pub fn read(resource: &ResourceVersion) -> Result<Self, PolicyError> {
let record = Record::<PolicyError>::open_reserving(
resource,
ResourceKind::Policy,
Self::SCHEMA,
Self::KNOWN_FIELDS,
BOOTSTRAP_OWNED_FIELDS,
)?;
let tenant = record.tenant()?;
let scope = match record.optional_project()? {
Some(project) => PolicyScope::Project { tenant, project },
None => PolicyScope::Tenant(tenant),
};
record.identity(scope, scope.resource_id())?;
if resource.scope != scope.resource_scope() {
return Err(PolicyError::ScopeMismatch {
reference: resource.reference,
declared: scope,
scoped: resource.scope.clone(),
});
}
let bound = |field: &'static str, source: InvalidPolicy| PolicyError::FieldRange {
reference: resource.reference,
field,
source,
};
let epoch = PolicyEpoch::new(record.integer(EPOCH_FIELD)?)
.map_err(|source| bound(EPOCH_FIELD, source))?;
let budget = BudgetPolicy::new(
record.integer(BUDGET_LIMIT_FIELD)?,
record.optional_integer(NAMESPACE_BUDGET_LIMIT_FIELD)?,
record.integer(RESERVATION_TTL_FIELD)?,
)
.map_err(|source| bound(RESERVATION_TTL_FIELD, source))?;
let max_in_flight = record.integer(MAX_IN_FLIGHT_FIELD)?;
let lease_ttl = record.integer(LEASE_TTL_FIELD)?;
let concurrency = ConcurrencyPolicy::new(max_in_flight, lease_ttl).map_err(|source| {
let field = if max_in_flight == 0 {
MAX_IN_FLIGHT_FIELD
} else {
LEASE_TTL_FIELD
};
bound(field, source)
})?;
Ok(Self {
scope,
epoch,
budget,
concurrency,
revocation: RevocationPolicy::new(record.integer(MINIMUM_TOKEN_EPOCH_FIELD)?),
})
}
pub fn transition(&self, next: &Self) -> PolicyTransition {
PolicyTransition::between(self, next)
}
fn same_content(&self, other: &Self) -> bool {
self.content() == other.content()
}
}
impl Canonical for PolicyBody {
fn canonical(&self) -> CanonicalValue {
let mut fields = vec![
(SCHEMA_FIELD, CanonicalValue::string(Self::SCHEMA)),
(
TENANT_ID_FIELD,
CanonicalValue::string(self.scope.tenant().to_string()),
),
(EPOCH_FIELD, CanonicalValue::integer(self.epoch.get())),
(
BUDGET_LIMIT_FIELD,
CanonicalValue::integer(self.budget.subject_limit_microdollars),
),
(
RESERVATION_TTL_FIELD,
CanonicalValue::integer(self.budget.reservation_ttl_seconds),
),
(
MAX_IN_FLIGHT_FIELD,
CanonicalValue::integer(self.concurrency.max_in_flight_per_subject),
),
(
LEASE_TTL_FIELD,
CanonicalValue::integer(self.concurrency.lease_ttl_seconds),
),
(
MINIMUM_TOKEN_EPOCH_FIELD,
CanonicalValue::integer(self.revocation.minimum_token_epoch),
),
];
if let Some(project) = self.scope.project() {
fields.push((
PROJECT_ID_FIELD,
CanonicalValue::string(project.to_string()),
));
}
if let Some(limit) = self.budget.namespace_limit_microdollars {
fields.push((NAMESPACE_BUDGET_LIMIT_FIELD, CanonicalValue::integer(limit)));
}
CanonicalValue::map(fields)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PolicyContent(Checksum);
impl PolicyContent {
fn of(body: &PolicyBody) -> Self {
let mut bytes = Vec::new();
for text in [
body.scope.tenant().to_string(),
body.scope
.project()
.map_or_else(String::new, |project| project.to_string()),
] {
bytes.extend_from_slice(&(text.len() as u64).to_be_bytes());
bytes.extend_from_slice(text.as_bytes());
}
for number in [
body.budget.subject_limit_microdollars,
u64::from(body.budget.namespace_limit_microdollars.is_some()),
body.budget.namespace_limit_microdollars.unwrap_or(0),
body.budget.reservation_ttl_seconds,
body.concurrency.max_in_flight_per_subject,
body.concurrency.lease_ttl_seconds,
body.revocation.minimum_token_epoch,
] {
bytes.extend_from_slice(&number.to_be_bytes());
}
Self(Checksum::of(&bytes))
}
pub const fn digest(&self) -> Checksum {
self.0
}
}
impl fmt::Display for PolicyContent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PolicyGeneration {
scope: PolicyScope,
epoch: PolicyEpoch,
source: RevisionId,
content: PolicyContent,
}
impl PolicyGeneration {
pub const fn new(
scope: PolicyScope,
epoch: PolicyEpoch,
source: RevisionId,
content: PolicyContent,
) -> Self {
Self {
scope,
epoch,
source,
content,
}
}
pub const fn scope(&self) -> PolicyScope {
self.scope
}
pub const fn epoch(&self) -> PolicyEpoch {
self.epoch
}
pub const fn source(&self) -> RevisionId {
self.source
}
pub const fn content(&self) -> PolicyContent {
self.content
}
pub fn supersedes(&self, other: &Self) -> bool {
self.scope == other.scope && self.epoch > other.epoch
}
pub fn same_policy(&self, other: &Self) -> bool {
self.scope == other.scope && self.epoch == other.epoch && self.content == other.content
}
pub fn carries_forward(&self, other: &Self) -> bool {
self.same_policy(other) && self.source != other.source
}
}
impl fmt::Display for PolicyGeneration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"epoch {} of {} from revision {}",
self.epoch, self.scope, self.source
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Offered {
pub offered: PolicyGeneration,
pub active: PolicyGeneration,
}
impl Offered {
pub const fn new(offered: PolicyGeneration, active: PolicyGeneration) -> Self {
Self { offered, active }
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Fenced {
#[error("writer holds {}, which the active {} has moved past", .0.offered, .0.active)]
Stale(Box<Offered>),
#[error("writer holds {}, which is ahead of the active {}", .0.offered, .0.active)]
Ahead(Box<Offered>),
#[error("writer holds {}, which claims the epoch of the active {}", .0.offered, .0.active)]
Forked(Box<Offered>),
#[error(
"writer holds {}, which is not the policy the active {} enforces",
.0.offered,
.0.active
)]
OtherScope(Box<Offered>),
}
impl Fenced {
pub fn writer(&self) -> PolicyGeneration {
self.offered().offered
}
pub fn active(&self) -> PolicyGeneration {
self.offered().active
}
fn offered(&self) -> &Offered {
match self {
Self::Stale(offered)
| Self::Ahead(offered)
| Self::Forked(offered)
| Self::OtherScope(offered) => offered,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("cannot adopt {} over the active {}", .0.offered, .0.active)]
pub struct NotAnAdvance(pub Box<Offered>);
impl NotAnAdvance {
pub fn next(&self) -> PolicyGeneration {
self.0.offered
}
pub fn active(&self) -> PolicyGeneration {
self.0.active
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PolicyFence {
active: PolicyGeneration,
}
impl PolicyFence {
pub const fn new(active: PolicyGeneration) -> Self {
Self { active }
}
pub const fn active(&self) -> PolicyGeneration {
self.active
}
pub fn admit(&self, writer: PolicyGeneration) -> Result<(), Fenced> {
if writer.same_policy(&self.active) {
return Ok(());
}
let offered = Box::new(Offered::new(writer, self.active));
Err(if writer.scope != self.active.scope {
Fenced::OtherScope(offered)
} else if writer.epoch < self.active.epoch {
Fenced::Stale(offered)
} else if writer.epoch > self.active.epoch {
Fenced::Ahead(offered)
} else {
Fenced::Forked(offered)
})
}
pub fn adopt(&mut self, next: PolicyGeneration) -> Result<(), NotAnAdvance> {
if !next.supersedes(&self.active) && !next.carries_forward(&self.active) {
return Err(NotAnAdvance(Box::new(Offered::new(next, self.active))));
}
self.active = next;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TransitionClass {
Live,
Drain,
MigrationRequired,
Refused,
}
impl TransitionClass {
pub const fn as_str(self) -> &'static str {
match self {
Self::Live => "live",
Self::Drain => "drain",
Self::MigrationRequired => "migration-required",
Self::Refused => "refused",
}
}
}
impl fmt::Display for TransitionClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TransitionReason {
ScopeChanged,
EpochRegressed,
EpochNotAdvanced,
Republished,
BudgetRaised,
BudgetLowered,
ScopeCapEnabled,
ScopeCapDisabled,
ScopeCapRaised,
ScopeCapLowered,
ReservationTtlExtended,
ReservationTtlShortened,
ConcurrencyRaised,
ConcurrencyLowered,
LeaseTtlExtended,
LeaseTtlShortened,
TokenFloorRaised,
TokenFloorLowered,
}
impl TransitionReason {
pub const fn class(self) -> TransitionClass {
match self {
Self::ScopeChanged
| Self::EpochRegressed
| Self::EpochNotAdvanced
| Self::TokenFloorLowered => TransitionClass::Refused,
Self::ScopeCapEnabled | Self::ScopeCapDisabled => TransitionClass::MigrationRequired,
Self::BudgetLowered
| Self::ScopeCapLowered
| Self::ReservationTtlShortened
| Self::ConcurrencyLowered
| Self::LeaseTtlShortened => TransitionClass::Drain,
Self::Republished
| Self::BudgetRaised
| Self::ScopeCapRaised
| Self::ReservationTtlExtended
| Self::ConcurrencyRaised
| Self::LeaseTtlExtended
| Self::TokenFloorRaised => TransitionClass::Live,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyTransition {
class: TransitionClass,
reasons: Vec<TransitionReason>,
}
impl PolicyTransition {
fn between(from: &PolicyBody, to: &PolicyBody) -> Self {
if from.scope != to.scope {
return Self::of(vec![TransitionReason::ScopeChanged]);
}
if to.epoch < from.epoch {
return Self::of(vec![TransitionReason::EpochRegressed]);
}
if to.epoch == from.epoch {
return if from.same_content(to) {
Self::of(Vec::new())
} else {
Self::of(vec![TransitionReason::EpochNotAdvanced])
};
}
if from.same_content(to) {
return Self::of(vec![TransitionReason::Republished]);
}
let mut reasons = Vec::new();
let (old, new) = (&from.budget, &to.budget);
push_ordered(
&mut reasons,
old.subject_limit_microdollars,
new.subject_limit_microdollars,
TransitionReason::BudgetRaised,
TransitionReason::BudgetLowered,
);
match (
old.namespace_limit_microdollars,
new.namespace_limit_microdollars,
) {
(None, Some(_)) => reasons.push(TransitionReason::ScopeCapEnabled),
(Some(_), None) => reasons.push(TransitionReason::ScopeCapDisabled),
(Some(old), Some(new)) => push_ordered(
&mut reasons,
old,
new,
TransitionReason::ScopeCapRaised,
TransitionReason::ScopeCapLowered,
),
(None, None) => {}
}
push_ordered(
&mut reasons,
old.reservation_ttl_seconds,
new.reservation_ttl_seconds,
TransitionReason::ReservationTtlExtended,
TransitionReason::ReservationTtlShortened,
);
let (old, new) = (&from.concurrency, &to.concurrency);
push_ordered(
&mut reasons,
old.max_in_flight_per_subject,
new.max_in_flight_per_subject,
TransitionReason::ConcurrencyRaised,
TransitionReason::ConcurrencyLowered,
);
push_ordered(
&mut reasons,
old.lease_ttl_seconds,
new.lease_ttl_seconds,
TransitionReason::LeaseTtlExtended,
TransitionReason::LeaseTtlShortened,
);
push_ordered(
&mut reasons,
from.revocation.minimum_token_epoch,
to.revocation.minimum_token_epoch,
TransitionReason::TokenFloorRaised,
TransitionReason::TokenFloorLowered,
);
Self::of(reasons)
}
fn of(mut reasons: Vec<TransitionReason>) -> Self {
reasons.sort_unstable();
reasons.dedup();
let class = reasons
.iter()
.map(|reason| reason.class())
.max()
.unwrap_or(TransitionClass::Live);
Self { class, reasons }
}
pub const fn class(&self) -> TransitionClass {
self.class
}
pub fn reasons(&self) -> &[TransitionReason] {
&self.reasons
}
pub fn is_live(&self) -> bool {
self.class == TransitionClass::Live
}
pub fn is_refused(&self) -> bool {
self.class == TransitionClass::Refused
}
}
fn push_ordered(
reasons: &mut Vec<TransitionReason>,
old: u64,
new: u64,
raised: TransitionReason,
lowered: TransitionReason,
) {
if new > old {
reasons.push(raised);
} else if new < old {
reasons.push(lowered);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyDocument {
pub reference: ResourceRef,
pub slug: Slug,
pub body: PolicyBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PolicySet {
documents: BTreeMap<PolicyScope, PolicyDocument>,
}
impl PolicySet {
pub fn of(state: &DesiredState) -> Result<Self, PolicyError> {
let mut set = Self::default();
for resource in state.resources() {
if resource.reference.kind != ResourceKind::Policy {
continue;
}
let body = PolicyBody::read(resource)?;
set.documents.insert(
body.scope(),
PolicyDocument {
reference: resource.reference,
slug: resource.slug.clone(),
body,
},
);
}
Ok(set)
}
pub fn documents(&self) -> impl ExactSizeIterator<Item = &PolicyDocument> {
self.documents.values()
}
pub fn document(&self, scope: PolicyScope) -> Option<&PolicyDocument> {
self.documents.get(&scope)
}
pub fn effective(&self, scope: PolicyScope) -> Option<&PolicyDocument> {
self.documents
.get(&scope)
.or_else(|| self.documents.get(&scope.fallback()?))
}
pub fn snapshot(&self, source: RevisionId) -> PolicySnapshot {
PolicySnapshot {
source,
documents: self
.documents
.iter()
.map(|(scope, document)| (*scope, document.body))
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicySnapshot {
source: RevisionId,
documents: BTreeMap<PolicyScope, PolicyBody>,
}
impl PolicySnapshot {
pub const fn source(&self) -> RevisionId {
self.source
}
pub fn effective(&self, scope: PolicyScope) -> Option<&PolicyBody> {
self.documents
.get(&scope)
.or_else(|| self.documents.get(&scope.fallback()?))
}
pub fn generation(&self, scope: PolicyScope) -> Option<PolicyGeneration> {
Some(self.effective(scope)?.generation(self.source))
}
pub fn fence(&self, scope: PolicyScope) -> Option<PolicyFence> {
Some(PolicyFence::new(self.generation(scope)?))
}
pub fn scopes(&self) -> impl ExactSizeIterator<Item = PolicyScope> + '_ {
self.documents.keys().copied()
}
}
impl BodyError for PolicyError {
fn kind(reference: ResourceRef, expected: ResourceKind, found: ResourceKind) -> Self {
Self::Kind {
reference,
expected,
found,
}
}
fn not_inline(reference: ResourceRef) -> Self {
Self::NotInline { reference }
}
fn not_a_record(reference: ResourceRef) -> Self {
Self::NotARecord { reference }
}
fn schema(reference: ResourceRef, expected: &'static str, found: String) -> Self {
Self::Schema {
reference,
expected,
found,
}
}
fn missing_field(reference: ResourceRef, field: &'static str) -> Self {
Self::MissingField { reference, field }
}
fn unknown_field(reference: ResourceRef, schema: &'static str, field: String) -> Self {
Self::UnknownField {
reference,
schema,
field,
}
}
fn reserved_field(reference: ResourceRef, _schema: &'static str, field: String) -> Self {
Self::BootstrapOwned { reference, field }
}
fn field_type(reference: ResourceRef, field: &'static str) -> Self {
Self::FieldType { reference, field }
}
fn malformed_id(reference: ResourceRef, field: &'static str, source: InvalidId) -> Self {
Self::MalformedId {
reference,
field,
source,
}
}
fn malformed_display_name(
reference: ResourceRef,
field: &'static str,
_source: InvalidDisplayName,
) -> Self {
Self::FieldType { reference, field }
}
fn identity_mismatch(reference: ResourceRef, declared: String, identity: ResourceId) -> Self {
Self::IdentityMismatch {
reference,
declared,
identity,
}
}
}
#[cfg(test)]
mod tests {
use super::super::canonical::SerializerVersion;
use super::super::fixtures::{
DESIRED_STATE_RESOURCES, candidate, policy_body, project_id, project_policy,
project_policy_body, revision_id, state, state_with_policy, tenant_id, tenant_policy,
tenant_policy_body,
};
use super::super::mutation::ExpectedRevision;
use super::super::revision::{
BodySkew, IntegrityError, LoadedRevision, RevisionManifest, ValidationError,
};
use super::*;
use std::time::SystemTime;
fn tenant_scope() -> PolicyScope {
PolicyScope::Tenant(tenant_id(1))
}
fn project_scope() -> PolicyScope {
PolicyScope::Project {
tenant: tenant_id(1),
project: project_id(2),
}
}
fn slug() -> Slug {
Slug::parse("limits").expect("test slug")
}
fn with_fields(
resource: &ResourceVersion,
edit: impl FnOnce(&mut Vec<(String, CanonicalValue)>),
) -> ResourceVersion {
let ResourceBody::Inline(CanonicalValue::Map(fields)) = &resource.body else {
panic!("a policy fixture body is an inline record");
};
let mut fields = fields.clone();
edit(&mut fields);
ResourceVersion {
body: ResourceBody::Inline(CanonicalValue::Map(fields)),
..resource.clone()
}
}
fn set(fields: &mut Vec<(String, CanonicalValue)>, field: &str, value: CanonicalValue) {
fields.retain(|(name, _)| name != field);
fields.push((field.to_owned(), value));
}
fn edited(edit: impl FnOnce(&mut Vec<(String, CanonicalValue)>)) -> ResourceVersion {
with_fields(&tenant_policy(1, 1), edit)
}
#[test]
fn a_document_round_trips_through_its_envelope_and_its_canonical_bytes() {
let body = tenant_policy_body(1, 1);
let resource = tenant_policy(1, 1);
assert_eq!(PolicyBody::read(&resource).unwrap(), body);
assert_eq!(
resource.reference.id,
ResourceId::new(tenant_id(1).uuid()),
"a tenant's policy is written under the tenant it governs"
);
assert_eq!(resource.scope, ResourceScope::Tenant(tenant_id(1)));
let project = project_policy_body(1, 2, 1);
let resource = project_policy(1, 2, 1);
assert_eq!(PolicyBody::read(&resource).unwrap(), project);
assert_eq!(resource.reference.id, ResourceId::new(project_id(2).uuid()));
assert_eq!(
body.checksum().unwrap(),
tenant_policy_body(1, 1).checksum().unwrap()
);
assert_ne!(
body.checksum().unwrap(),
tenant_policy_body(1, 2).checksum().unwrap(),
"the epoch is part of the document, so republishing changes its bytes"
);
assert_ne!(body.checksum().unwrap(), project.checksum().unwrap());
let bytes = SerializerVersion::V1.encode(&project.canonical()).unwrap();
let decoded = SerializerVersion::V1
.decode(&bytes)
.expect("a policy body is canonical, so storage returns what it took");
assert_eq!(
SerializerVersion::V1.encode(&decoded).unwrap(),
bytes,
"the decoded body re-encodes to the bytes storage holds"
);
assert_eq!(
PolicyBody::read(&ResourceVersion {
body: ResourceBody::Inline(decoded),
..resource
})
.unwrap(),
project,
"and reads back as the same document"
);
assert!(
matches!(
body.canonical(),
CanonicalValue::Map(ref fields)
if fields.iter().any(|(name, value)|
name == SCHEMA_FIELD && *value == CanonicalValue::string(POLICY_SCHEMA))
),
"the schema identifier is inside the checksummed bytes"
);
}
#[test]
fn an_absent_optional_field_is_a_statement_rather_than_an_omission() {
let uncapped = tenant_policy_body(1, 1);
assert_eq!(uncapped.budget().namespace_limit_microdollars(), None);
let CanonicalValue::Map(fields) = uncapped.canonical() else {
panic!("a policy body is a record");
};
assert!(
!fields
.iter()
.any(|(name, _)| name == NAMESPACE_BUDGET_LIMIT_FIELD),
"no scope-wide cap is the absence of the key, not a cap of zero"
);
let capped = PolicyBody::new(
tenant_scope(),
PolicyEpoch::FIRST,
BudgetPolicy::new(1_000_000, Some(0), 60).unwrap(),
ConcurrencyPolicy::new(8, 30).unwrap(),
RevocationPolicy::new(1),
);
assert_ne!(
uncapped.checksum().unwrap(),
capped.checksum().unwrap(),
"a scope-wide cap of zero is a different document from no cap at all"
);
assert_eq!(
PolicyBody::read(&capped.version(slug())).unwrap(),
capped,
"and it reads back as the cap it is"
);
}
#[test]
fn a_body_is_bound_to_the_envelope_that_carries_it() {
let moved = ResourceVersion {
reference: ResourceRef::new(
ResourceKind::Policy,
ResourceId::new(project_id(9).uuid()),
ResourceVersionNumber::FIRST,
),
..tenant_policy(1, 1)
};
assert!(
matches!(
PolicyBody::read(&moved),
Err(PolicyError::IdentityMismatch { .. })
),
"{:?}",
PolicyBody::read(&moved)
);
let rescoped = ResourceVersion {
scope: ResourceScope::Project {
tenant: tenant_id(1),
project: project_id(2),
},
..tenant_policy(1, 1)
};
assert_eq!(
PolicyBody::read(&rescoped),
Err(PolicyError::ScopeMismatch {
reference: rescoped.reference,
declared: tenant_scope(),
scoped: rescoped.scope.clone(),
})
);
let mistyped = ResourceVersion {
reference: ResourceRef::new(
ResourceKind::ProviderCredential,
tenant_policy(1, 1).reference.id,
ResourceVersionNumber::FIRST,
),
..tenant_policy(1, 1)
};
assert!(matches!(
PolicyBody::read(&mistyped),
Err(PolicyError::Kind { .. })
));
}
#[test]
fn a_strict_reader_refuses_every_body_it_does_not_fully_understand() {
let reference = tenant_policy(1, 1).reference;
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(
fields,
SCHEMA_FIELD,
CanonicalValue::string("axond.policy.v2"),
);
})),
Err(PolicyError::Schema {
reference,
expected: POLICY_SCHEMA,
found: "axond.policy.v2".to_owned(),
})
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, "burst_limit", CanonicalValue::integer(7u32));
})),
Err(PolicyError::UnknownField {
reference,
schema: POLICY_SCHEMA,
field: "burst_limit".to_owned(),
}),
"a field a newer release added is refused rather than ignored"
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
fields.retain(|(name, _)| name != EPOCH_FIELD);
})),
Err(PolicyError::MissingField {
reference,
field: EPOCH_FIELD,
})
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, EPOCH_FIELD, CanonicalValue::string("4"));
})),
Err(PolicyError::FieldType {
reference,
field: EPOCH_FIELD,
})
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, EPOCH_FIELD, CanonicalValue::integer(0u32));
})),
Err(PolicyError::FieldRange {
reference,
field: EPOCH_FIELD,
source: InvalidPolicy::TooSmall { value: 0, min: 1 },
}),
"zero is not an epoch, so an unset counter cannot read as a valid one"
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, LEASE_TTL_FIELD, CanonicalValue::integer(-30i32));
})),
Err(PolicyError::FieldType {
reference,
field: LEASE_TTL_FIELD,
}),
"a value that is not an unsigned counter is a shape refusal, not a bound"
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, LEASE_TTL_FIELD, CanonicalValue::integer(0u32));
})),
Err(PolicyError::FieldRange {
reference,
field: LEASE_TTL_FIELD,
source: InvalidPolicy::TooSmall { value: 0, min: 1 },
}),
"a refusal names the field that broke a bound, not the one checked first"
);
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, MAX_IN_FLIGHT_FIELD, CanonicalValue::integer(0u32));
})),
Err(PolicyError::FieldRange {
reference,
field: MAX_IN_FLIGHT_FIELD,
source: InvalidPolicy::TooSmall { value: 0, min: 1 },
})
);
assert!(matches!(
PolicyBody::read(&edited(|fields| {
set(
fields,
TENANT_ID_FIELD,
CanonicalValue::string("not-a-uuid"),
);
})),
Err(PolicyError::MalformedId {
field: TENANT_ID_FIELD,
..
})
));
assert_eq!(
PolicyBody::read(&ResourceVersion {
body: ResourceBody::Inline(CanonicalValue::string(POLICY_SCHEMA)),
..tenant_policy(1, 1)
}),
Err(PolicyError::NotARecord { reference })
);
assert_eq!(
ConcurrencyPolicy::new(0, 30),
Err(InvalidPolicy::TooSmall { value: 0, min: 1 })
);
assert_eq!(
BudgetPolicy::new(1, None, 0),
Err(InvalidPolicy::TooSmall { value: 0, min: 1 })
);
}
#[test]
fn a_published_document_may_not_name_a_field_bootstrap_owns() {
let reference = tenant_policy(1, 1).reference;
for field in BOOTSTRAP_OWNED_FIELDS {
assert_eq!(
PolicyBody::read(&edited(|fields| {
set(fields, field, CanonicalValue::string("allow"));
})),
Err(PolicyError::BootstrapOwned {
reference,
field: (*field).to_owned(),
}),
"`{field}` is the bootstrap file's, and a publication may not set it"
);
assert!(
!PolicyBody::KNOWN_FIELDS.contains(field),
"`{field}` must not also be a field this schema defines"
);
}
assert!(
BOOTSTRAP_OWNED_FIELDS.contains(&"on_unavailable")
&& BOOTSTRAP_OWNED_FIELDS.contains(&"backend"),
"backend identity and the unavailable stance are the two that must never be publishable"
);
assert!(
!PolicyBody::read(&edited(|fields| {
set(fields, "on_unavailable", CanonicalValue::string("allow"));
}))
.expect_err("a bootstrap-owned field is refused")
.is_incompatible(),
"a boundary this schema will never cross must not read as a version skew"
);
}
#[test]
fn a_body_this_build_cannot_read_is_a_skew_and_a_rewritten_one_is_damage() {
let reference = tenant_policy(1, 1).reference;
for error in [
PolicyError::Schema {
reference,
expected: POLICY_SCHEMA,
found: "axond.policy.v2".to_owned(),
},
PolicyError::UnknownField {
reference,
schema: POLICY_SCHEMA,
field: "burst_limit".to_owned(),
},
PolicyError::MissingField {
reference,
field: SCHEMA_FIELD,
},
] {
assert!(error.is_incompatible(), "{error}");
assert_eq!(error.reference(), reference);
}
for error in [
PolicyError::MissingField {
reference,
field: EPOCH_FIELD,
},
PolicyError::FieldType {
reference,
field: EPOCH_FIELD,
},
PolicyError::IdentityMismatch {
reference,
declared: tenant_scope().to_string(),
identity: reference.id,
},
PolicyError::FieldType {
reference,
field: LEASE_TTL_FIELD,
},
] {
assert!(
!error.is_incompatible(),
"a refusal inside a body that declared this schema points at storage: {error}"
);
}
let below_a_bound = PolicyError::FieldRange {
reference,
field: LEASE_TTL_FIELD,
source: InvalidPolicy::TooSmall { value: 0, min: 1 },
};
assert!(below_a_bound.is_incompatible(), "{below_a_bound}");
}
#[test]
fn publication_and_hydration_read_policy_the_way_they_read_tenancy() {
let state = state_with_policy();
state.validate().expect("the fixture documents are valid");
assert_eq!(
state.resources().len(),
DESIRED_STATE_RESOURCES + 2,
"a document per scope, and no other resource added"
);
let mut broken = DesiredState::new();
for resource in state.resources() {
let resource = if resource.reference.kind == ResourceKind::Policy
&& resource.scope == ResourceScope::Tenant(tenant_id(1))
{
edited(|fields| {
set(fields, EPOCH_FIELD, CanonicalValue::integer(0u32));
})
} else {
resource.clone()
};
broken.insert(resource).expect("distinct references");
}
for blob in state.blobs() {
broken.declare_blob(*blob);
}
assert!(
matches!(
broken.validate(),
Err(ValidationError::Policy(PolicyError::FieldRange { .. }))
),
"{:?}",
broken.validate()
);
let candidate = candidate(ExpectedRevision::Empty, "policy", state_with_policy());
let manifest =
RevisionManifest::of(revision_id(1), None, SystemTime::UNIX_EPOCH, &candidate)
.expect("the fixture state is publishable");
let mut newer = DesiredState::new();
for resource in candidate.state.resources() {
let resource = if resource.reference.kind == ResourceKind::Policy
&& resource.scope == ResourceScope::Tenant(tenant_id(1))
{
edited(|fields| {
set(
fields,
SCHEMA_FIELD,
CanonicalValue::string("axond.policy.v2"),
);
})
} else {
resource.clone()
};
newer.insert(resource).expect("distinct references");
}
for blob in candidate.state.blobs() {
newer.declare_blob(*blob);
}
let error = LoadedRevision::assemble(manifest, newer)
.expect_err("a policy schema from another release must not hydrate");
assert!(
matches!(
error,
IntegrityError::Incompatible(BodySkew::Policy(PolicyError::Schema { .. }))
),
"{error}"
);
assert!(error.is_incompatible());
assert_eq!(
match &error {
IntegrityError::Incompatible(skew) => skew.reference(),
other => panic!("{other}"),
},
tenant_policy(1, 1).reference,
"and it names the row an operator has to act on"
);
}
#[test]
fn an_effective_policy_is_one_whole_document_never_a_merge_of_two() {
let tenant = PolicyBody::new(
tenant_scope(),
PolicyEpoch::FIRST,
BudgetPolicy::new(9_000_000, Some(50_000_000), 900).unwrap(),
ConcurrencyPolicy::new(64, 600).unwrap(),
RevocationPolicy::new(7),
);
let project = PolicyBody::new(
project_scope(),
PolicyEpoch::FIRST,
BudgetPolicy::new(1_000, None, 30).unwrap(),
ConcurrencyPolicy::new(2, 15).unwrap(),
RevocationPolicy::new(1),
);
let mut with_documents = state();
with_documents
.insert(tenant.version(slug()))
.and_then(|state| state.insert(project.version(slug())))
.expect("one document per scope");
with_documents.validate().expect("both documents are valid");
let set = PolicySet::of(&with_documents).unwrap();
assert_eq!(set.documents().len(), 2);
assert_eq!(set.document(project_scope()).unwrap().body, project);
assert_eq!(
set.effective(project_scope()).unwrap().body,
project,
"a scope with its own document is governed by that document verbatim"
);
assert_eq!(
set.effective(project_scope())
.unwrap()
.body
.budget()
.namespace_limit_microdollars(),
None,
"the tenant's scope-wide cap is not inherited field by field"
);
let sibling = PolicyScope::Project {
tenant: tenant_id(1),
project: project_id(3),
};
assert_eq!(set.document(sibling), None);
assert_eq!(set.effective(sibling).unwrap().body, tenant);
assert_eq!(set.effective(PolicyScope::Tenant(tenant_id(4))), None);
assert_eq!(PolicySet::of(&state()).unwrap().documents().len(), 0);
}
#[test]
fn a_generation_is_an_epoch_and_the_revision_that_published_it() {
let set = PolicySet::of(&state_with_policy()).unwrap();
let (first, second) = (revision_id(1), revision_id(2));
let snapshot = set.snapshot(first);
assert_eq!(snapshot.source(), first);
assert_eq!(snapshot.scopes().len(), 2);
let content = tenant_policy_body(1, 1).content();
let generation = snapshot.generation(tenant_scope()).unwrap();
assert_eq!(generation.epoch(), PolicyEpoch::FIRST);
assert_eq!(generation.source(), first);
assert_eq!(generation.content(), content);
assert_eq!(
generation,
PolicyGeneration::new(tenant_scope(), PolicyEpoch::FIRST, first, content)
);
let carried = set.snapshot(second).generation(tenant_scope()).unwrap();
assert_ne!(
generation, carried,
"one epoch published by two revisions is two generations, not one"
);
assert!(
carried.carries_forward(&generation) && generation.carries_forward(&carried),
"but a revision that restates an unchanged document carries it forward, \
rather than forking it"
);
assert!(
carried.same_policy(&generation),
"so the two generations enforce one policy"
);
let sibling = PolicyScope::Project {
tenant: tenant_id(1),
project: project_id(3),
};
assert_eq!(
snapshot.effective(sibling).unwrap(),
&tenant_policy_body(1, 1)
);
assert_eq!(snapshot.generation(sibling), Some(generation));
assert_eq!(snapshot.generation(PolicyScope::Tenant(tenant_id(4))), None);
assert_eq!(snapshot.fence(PolicyScope::Tenant(tenant_id(4))), None);
}
#[test]
fn a_writer_of_any_generation_but_the_active_one_fails_closed() {
let (first, second) = (revision_id(1), revision_id(2));
let content = tenant_policy_body(1, 4).content();
let active =
PolicyGeneration::new(tenant_scope(), PolicyEpoch::new(4).unwrap(), first, content);
let fence = PolicyFence::new(active);
assert_eq!(fence.active(), active);
assert_eq!(fence.admit(active), Ok(()));
let stale =
PolicyGeneration::new(tenant_scope(), PolicyEpoch::new(3).unwrap(), first, content);
assert_eq!(
fence.admit(stale),
Err(Fenced::Stale(Box::new(Offered::new(stale, active))))
);
let ahead = PolicyGeneration::new(
tenant_scope(),
PolicyEpoch::new(5).unwrap(),
second,
content,
);
assert_eq!(
fence.admit(ahead),
Err(Fenced::Ahead(Box::new(Offered::new(ahead, active))))
);
let forked = PolicyGeneration::new(
tenant_scope(),
PolicyEpoch::new(4).unwrap(),
second,
PolicyBody::new(
tenant_scope(),
PolicyEpoch::new(4).unwrap(),
BudgetPolicy::new(9_000_000, None, 60).unwrap(),
ConcurrencyPolicy::new(8, 30).unwrap(),
RevocationPolicy::new(1),
)
.content(),
);
assert_eq!(
fence.admit(forked),
Err(Fenced::Forked(Box::new(Offered::new(forked, active))))
);
assert!(!forked.supersedes(&active) && !active.supersedes(&forked));
assert!(!forked.carries_forward(&active));
let mut fence = PolicyFence::new(active);
assert_eq!(fence.adopt(ahead), Ok(()));
assert_eq!(fence.active(), ahead);
assert_eq!(
fence.admit(active),
Err(Fenced::Stale(Box::new(Offered::new(active, ahead))))
);
assert_eq!(
fence.adopt(active),
Err(NotAnAdvance(Box::new(Offered::new(active, ahead))))
);
assert_eq!(
fence.adopt(forked),
Err(NotAnAdvance(Box::new(Offered::new(forked, ahead))))
);
assert_eq!(fence.active(), ahead, "a refused adoption changes nothing");
}
#[test]
fn a_fence_cannot_be_walked_onto_another_scopes_policy() {
let elsewhere = project_policy_body(2, 9, 99).generation(revision_id(2));
let active = tenant_policy_body(1, 4).generation(revision_id(1));
assert!(!elsewhere.supersedes(&active) && !elsewhere.carries_forward(&active));
assert!(!elsewhere.same_policy(&active));
let mut fence = PolicyFence::new(active);
assert_eq!(
fence.adopt(elsewhere),
Err(NotAnAdvance(Box::new(Offered::new(elsewhere, active))))
);
assert_eq!(fence.active(), active);
assert_eq!(
fence.admit(elsewhere),
Err(Fenced::OtherScope(Box::new(Offered::new(
elsewhere, active
)))),
"and a writer holding it is wired wrong rather than late or early"
);
}
#[test]
fn a_project_taking_its_tenants_document_is_fenced_on_that_document() {
let mut with_tenant_only = state();
with_tenant_only
.insert(tenant_policy_body(1, 1).version(slug()))
.expect("a tenant document");
let inherited = PolicySet::of(&with_tenant_only)
.unwrap()
.snapshot(revision_id(1));
let fallback = inherited.generation(project_scope()).unwrap();
assert_eq!(fallback.scope(), tenant_scope());
assert_eq!(fallback, inherited.generation(tenant_scope()).unwrap());
let mut with_project = state();
with_project
.insert(tenant_policy_body(1, 1).version(slug()))
.and_then(|state| state.insert(project_policy_body(1, 2, 1).version(slug())))
.expect("one document per scope");
let own = PolicySet::of(&with_project)
.unwrap()
.snapshot(revision_id(2))
.generation(project_scope())
.unwrap();
assert_eq!(own.scope(), project_scope());
let mut fence = inherited.fence(project_scope()).unwrap();
assert_eq!(
fence.adopt(own),
Err(NotAnAdvance(Box::new(Offered::new(own, fallback))))
);
assert_eq!(
fence.active(),
fallback,
"and it keeps enforcing the document it has"
);
}
#[test]
fn an_unchanged_document_carried_into_a_later_revision_stays_the_same_policy() {
let set = PolicySet::of(&state_with_policy()).unwrap();
let (published, carried) = (
set.snapshot(revision_id(1))
.generation(tenant_scope())
.unwrap(),
set.snapshot(revision_id(2))
.generation(tenant_scope())
.unwrap(),
);
let mut fence = PolicyFence::new(carried);
assert_eq!(fence.admit(published), Ok(()));
assert_eq!(PolicyFence::new(published).admit(carried), Ok(()));
let mut following = PolicyFence::new(published);
assert_eq!(following.adopt(carried), Ok(()));
assert_eq!(following.active(), carried);
assert_eq!(fence.adopt(published), Ok(()));
let forked = PolicyGeneration::new(
tenant_scope(),
published.epoch(),
revision_id(3),
PolicyBody::new(
tenant_scope(),
published.epoch(),
BudgetPolicy::new(9_000_000, None, 60).unwrap(),
ConcurrencyPolicy::new(8, 30).unwrap(),
RevocationPolicy::new(1),
)
.content(),
);
assert_eq!(
fence.adopt(forked),
Err(NotAnAdvance(Box::new(Offered::new(forked, published))))
);
assert!(matches!(fence.admit(forked), Err(Fenced::Forked(_))));
let body = tenant_policy_body(1, published.epoch().get());
assert!(body.transition(&body).reasons().is_empty());
assert_eq!(
body.content(),
PolicyBody::new(
body.scope(),
body.epoch().next(),
*body.budget(),
*body.concurrency(),
*body.revocation(),
)
.content(),
"advancing only the epoch restates the same policy"
);
let capped = |limit| {
PolicyBody::new(
tenant_scope(),
PolicyEpoch::FIRST,
BudgetPolicy::new(1_000_000, limit, 60).unwrap(),
ConcurrencyPolicy::new(8, 30).unwrap(),
RevocationPolicy::new(1),
)
.content()
};
assert_ne!(capped(None), capped(Some(0)));
assert_ne!(capped(Some(1)), capped(Some(2)));
}
#[test]
fn a_transition_is_classified_by_what_activating_it_would_require() {
let base = policy_body(tenant_scope(), 4);
let next = |body: PolicyBody| {
PolicyBody::new(
body.scope(),
body.epoch().next(),
*body.budget(),
*body.concurrency(),
*body.revocation(),
)
};
let republished = next(base);
assert_eq!(
base.transition(&republished),
PolicyTransition {
class: TransitionClass::Live,
reasons: vec![TransitionReason::Republished],
}
);
assert!(base.transition(&republished).is_live());
assert!(base.transition(&base).reasons().is_empty());
let raised = PolicyBody::new(
tenant_scope(),
PolicyEpoch::new(5).unwrap(),
BudgetPolicy::new(2_000_000, None, 120).unwrap(),
ConcurrencyPolicy::new(16, 60).unwrap(),
RevocationPolicy::new(2),
);
assert_eq!(base.transition(&raised).class(), TransitionClass::Live);
assert_eq!(
base.transition(&raised).reasons(),
[
TransitionReason::BudgetRaised,
TransitionReason::ReservationTtlExtended,
TransitionReason::ConcurrencyRaised,
TransitionReason::LeaseTtlExtended,
TransitionReason::TokenFloorRaised,
]
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.as_slice(),
"every reason that applies is reported, in one order"
);
let lowered = PolicyBody::new(
tenant_scope(),
PolicyEpoch::new(5).unwrap(),
BudgetPolicy::new(500_000, None, 30).unwrap(),
ConcurrencyPolicy::new(4, 15).unwrap(),
RevocationPolicy::new(1),
);
let transition = base.transition(&lowered);
assert_eq!(transition.class(), TransitionClass::Drain);
assert!(
transition
.reasons()
.contains(&TransitionReason::BudgetLowered)
);
assert!(
transition
.reasons()
.contains(&TransitionReason::ReservationTtlShortened)
);
let capped = PolicyBody::new(
tenant_scope(),
PolicyEpoch::new(5).unwrap(),
BudgetPolicy::new(1_000_000, Some(10_000_000), 60).unwrap(),
*base.concurrency(),
*base.revocation(),
);
assert_eq!(
base.transition(&capped),
PolicyTransition {
class: TransitionClass::MigrationRequired,
reasons: vec![TransitionReason::ScopeCapEnabled],
}
);
assert_eq!(
capped.transition(&next(capped)).class(),
TransitionClass::Live,
"republishing a capped document is not itself a migration"
);
let uncapped = PolicyBody::new(
capped.scope(),
capped.epoch().next(),
BudgetPolicy::new(1_000_000, None, 60).unwrap(),
*capped.concurrency(),
*capped.revocation(),
);
assert_eq!(
capped.transition(&uncapped).reasons(),
[TransitionReason::ScopeCapDisabled],
);
assert_eq!(
capped.transition(&uncapped).class(),
TransitionClass::MigrationRequired
);
let mixed = PolicyBody::new(
tenant_scope(),
PolicyEpoch::new(5).unwrap(),
BudgetPolicy::new(2_000_000, Some(1), 60).unwrap(),
ConcurrencyPolicy::new(1, 15).unwrap(),
*base.revocation(),
);
let transition = base.transition(&mixed);
assert_eq!(transition.class(), TransitionClass::MigrationRequired);
assert!(
transition
.reasons()
.contains(&TransitionReason::BudgetRaised)
);
assert!(
transition
.reasons()
.contains(&TransitionReason::ConcurrencyLowered)
);
assert!(TransitionClass::Live < TransitionClass::Drain);
assert!(TransitionClass::Drain < TransitionClass::MigrationRequired);
assert!(TransitionClass::MigrationRequired < TransitionClass::Refused);
assert_eq!(
TransitionClass::MigrationRequired.to_string(),
"migration-required"
);
}
#[test]
fn a_change_the_epoch_does_not_carry_is_refused_rather_than_applied() {
let base = policy_body(tenant_scope(), 4);
let same_epoch = PolicyBody::new(
tenant_scope(),
base.epoch(),
BudgetPolicy::new(2_000_000, None, 60).unwrap(),
*base.concurrency(),
*base.revocation(),
);
let transition = base.transition(&same_epoch);
assert!(transition.is_refused());
assert_eq!(transition.reasons(), [TransitionReason::EpochNotAdvanced]);
let regressed = PolicyBody::new(
tenant_scope(),
PolicyEpoch::new(3).unwrap(),
*base.budget(),
*base.concurrency(),
*base.revocation(),
);
assert_eq!(
base.transition(®ressed).reasons(),
[TransitionReason::EpochRegressed]
);
let unrevoked = PolicyBody::new(
tenant_scope(),
base.epoch().next(),
*base.budget(),
*base.concurrency(),
RevocationPolicy::new(base.revocation().minimum_token_epoch() - 1),
);
let transition = base.transition(&unrevoked);
assert!(transition.is_refused());
assert_eq!(transition.reasons(), [TransitionReason::TokenFloorLowered]);
let elsewhere = policy_body(project_scope(), 5);
assert_eq!(
base.transition(&elsewhere).reasons(),
[TransitionReason::ScopeChanged]
);
assert!(base.transition(&elsewhere).is_refused());
}
}