use std::collections::BTreeSet;
use super::canonical::{Canonical, CanonicalValue, Checksum};
use super::ids::{ProjectId, ResourceId, Slug, TenantId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ResourceKind {
Tenant,
Project,
Identity,
Provider,
ProviderCredential,
CatalogModel,
ModelEnablement,
Price,
Alias,
Policy,
}
impl ResourceKind {
pub const ALL: &'static [Self] = &[
Self::Tenant,
Self::Project,
Self::Identity,
Self::Provider,
Self::ProviderCredential,
Self::CatalogModel,
Self::ModelEnablement,
Self::Price,
Self::Alias,
Self::Policy,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Tenant => "tenant",
Self::Project => "project",
Self::Identity => "identity",
Self::Provider => "provider",
Self::ProviderCredential => "provider-credential",
Self::CatalogModel => "catalog-model",
Self::ModelEnablement => "model-enablement",
Self::Price => "price",
Self::Alias => "alias",
Self::Policy => "policy",
}
}
pub const fn permits(self, scope: &ResourceScope) -> bool {
match self {
Self::Tenant | Self::CatalogModel => matches!(scope, ResourceScope::Deployment),
Self::Price => matches!(
scope,
ResourceScope::Deployment
| ResourceScope::Tenant(_)
| ResourceScope::Project { .. }
),
Self::Project => matches!(scope, ResourceScope::Tenant(_)),
Self::Identity => true,
_ => matches!(
scope,
ResourceScope::Tenant(_) | ResourceScope::Project { .. }
),
}
}
}
impl Canonical for ResourceKind {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::string(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ResourceScope {
Deployment,
Tenant(TenantId),
Project {
tenant: TenantId,
project: ProjectId,
},
}
impl ResourceScope {
pub const fn tenant(&self) -> Option<TenantId> {
match self {
Self::Deployment => None,
Self::Tenant(tenant) | Self::Project { tenant, .. } => Some(*tenant),
}
}
pub fn contains(&self, inner: &Self) -> bool {
match (self, inner) {
(Self::Deployment, _) => true,
(Self::Tenant(outer), Self::Tenant(tenant) | Self::Project { tenant, .. }) => {
outer == tenant
}
(Self::Tenant(_) | Self::Project { .. }, Self::Deployment) => false,
(Self::Project { .. }, Self::Tenant(_)) => false,
(Self::Project { .. }, Self::Project { .. }) => self == inner,
}
}
}
impl std::fmt::Display for ResourceScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deployment => f.write_str("deployment scope"),
Self::Tenant(tenant) => write!(f, "{tenant}"),
Self::Project { tenant, project } => write!(f, "{tenant}/{project}"),
}
}
}
impl Canonical for ResourceScope {
fn canonical(&self) -> CanonicalValue {
match self {
Self::Deployment => {
CanonicalValue::map([("kind", CanonicalValue::string("deployment"))])
}
Self::Tenant(tenant) => CanonicalValue::map([
("kind", CanonicalValue::string("tenant")),
("tenant", CanonicalValue::string(tenant.to_string())),
]),
Self::Project { tenant, project } => CanonicalValue::map([
("kind", CanonicalValue::string("project")),
("tenant", CanonicalValue::string(tenant.to_string())),
("project", CanonicalValue::string(project.to_string())),
]),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResourceVersionNumber(u64);
impl ResourceVersionNumber {
pub const FIRST: Self = Self(1);
pub const fn new(version: u64) -> Option<Self> {
if version == 0 {
None
} else {
Some(Self(version))
}
}
pub const fn get(self) -> u64 {
self.0
}
pub const fn next(self) -> Self {
Self(self.0 + 1)
}
}
impl std::fmt::Display for ResourceVersionNumber {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "v{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResourceRef {
pub kind: ResourceKind,
pub id: ResourceId,
pub version: ResourceVersionNumber,
}
impl ResourceRef {
pub const fn new(kind: ResourceKind, id: ResourceId, version: ResourceVersionNumber) -> Self {
Self { kind, id, version }
}
pub const fn at(self, version: ResourceVersionNumber) -> Self {
Self { version, ..self }
}
pub fn same_resource(&self, other: &Self) -> bool {
self.kind == other.kind && self.id == other.id
}
}
impl std::fmt::Display for ResourceRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}@{}", self.kind.as_str(), self.id, self.version)
}
}
impl Canonical for ResourceRef {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::map([
("kind", self.kind.canonical()),
("id", CanonicalValue::string(self.id.to_string())),
("version", CanonicalValue::integer(self.version.get())),
])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BlobKind {
CatalogSnapshot,
PriceBook,
PolicyBundle,
}
impl BlobKind {
pub const ALL: &'static [Self] = &[Self::CatalogSnapshot, Self::PriceBook, Self::PolicyBundle];
pub const fn as_str(self) -> &'static str {
match self {
Self::CatalogSnapshot => "catalog-snapshot",
Self::PriceBook => "price-book",
Self::PolicyBundle => "policy-bundle",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BlobRef {
pub kind: BlobKind,
pub digest: Checksum,
pub size_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BlobError {
#[error("blob {expected} is {actual_bytes} bytes, not the referenced {expected_bytes}")]
Size {
expected: Checksum,
expected_bytes: u64,
actual_bytes: u64,
},
#[error("blob payload hashes to {actual}, not the referenced {expected}")]
Digest {
expected: Checksum,
actual: Checksum,
},
}
impl BlobRef {
pub fn of(kind: BlobKind, payload: &[u8]) -> Self {
Self {
kind,
digest: Checksum::of(payload),
size_bytes: payload.len() as u64,
}
}
pub fn verify(&self, payload: &[u8]) -> Result<(), BlobError> {
if payload.len() as u64 != self.size_bytes {
return Err(BlobError::Size {
expected: self.digest,
expected_bytes: self.size_bytes,
actual_bytes: payload.len() as u64,
});
}
let actual = Checksum::of(payload);
if actual != self.digest {
return Err(BlobError::Digest {
expected: self.digest,
actual,
});
}
Ok(())
}
}
impl std::fmt::Display for BlobRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}/{} ({} bytes)",
self.kind.as_str(),
self.digest,
self.size_bytes
)
}
}
impl Canonical for BlobRef {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::map([
("kind", CanonicalValue::string(self.kind.as_str())),
(
"digest",
CanonicalValue::Bytes(self.digest.as_bytes().to_vec()),
),
("size_bytes", CanonicalValue::integer(self.size_bytes)),
])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceBody {
Inline(CanonicalValue),
Blob(BlobRef),
}
impl ResourceBody {
pub const fn blob(&self) -> Option<&BlobRef> {
match self {
Self::Inline(_) => None,
Self::Blob(reference) => Some(reference),
}
}
}
impl Canonical for ResourceBody {
fn canonical(&self) -> CanonicalValue {
match self {
Self::Inline(value) => CanonicalValue::map([
("form", CanonicalValue::string("inline")),
("value", value.clone()),
]),
Self::Blob(reference) => CanonicalValue::map([
("form", CanonicalValue::string("blob")),
("blob", reference.canonical()),
]),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceVersion {
pub reference: ResourceRef,
pub scope: ResourceScope,
pub slug: Slug,
pub body: ResourceBody,
pub depends_on: BTreeSet<ResourceRef>,
}
impl ResourceVersion {
pub fn new(
reference: ResourceRef,
scope: ResourceScope,
slug: Slug,
body: ResourceBody,
) -> Self {
Self {
reference,
scope,
slug,
body,
depends_on: BTreeSet::new(),
}
}
pub fn depending_on(mut self, references: impl IntoIterator<Item = ResourceRef>) -> Self {
self.depends_on.extend(references);
self
}
pub fn content_checksum(&self) -> Result<Checksum, super::canonical::CanonicalError> {
self.checksum()
}
}
impl Canonical for ResourceVersion {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::map([
("reference", self.reference.canonical()),
("scope", self.scope.canonical()),
("slug", CanonicalValue::string(self.slug.as_str())),
("body", self.body.canonical()),
(
"depends_on",
CanonicalValue::set(self.depends_on.iter().map(Canonical::canonical)),
),
])
}
}
#[cfg(test)]
mod tests {
use super::super::ids::Uuid7;
use super::*;
fn resource_id(seed: u64) -> ResourceId {
ResourceId::new(Uuid7::from_parts(seed, 0, seed).unwrap())
}
fn tenant_id(seed: u64) -> TenantId {
TenantId::new(Uuid7::from_parts(seed, 0, seed).unwrap())
}
fn reference(kind: ResourceKind, seed: u64) -> ResourceRef {
ResourceRef::new(kind, resource_id(seed), ResourceVersionNumber::FIRST)
}
#[test]
fn scope_rules_place_every_kind_exactly_where_it_belongs() {
let tenant = tenant_id(1);
let project = ResourceScope::Project {
tenant,
project: ProjectId::new(Uuid7::from_parts(2, 0, 2).unwrap()),
};
assert!(ResourceKind::Tenant.permits(&ResourceScope::Deployment));
assert!(!ResourceKind::Tenant.permits(&ResourceScope::Tenant(tenant)));
assert!(ResourceKind::CatalogModel.permits(&ResourceScope::Deployment));
assert!(ResourceKind::Project.permits(&ResourceScope::Tenant(tenant)));
assert!(!ResourceKind::Project.permits(&project));
for scope in [
ResourceScope::Deployment,
ResourceScope::Tenant(tenant),
project.clone(),
] {
assert!(
ResourceKind::Identity.permits(&scope),
"identity at {scope}"
);
}
assert!(ResourceKind::Price.permits(&ResourceScope::Deployment));
assert!(ResourceKind::Price.permits(&ResourceScope::Tenant(tenant)));
assert!(ResourceKind::Price.permits(&project));
for kind in ResourceKind::ALL {
if matches!(
kind,
ResourceKind::Tenant
| ResourceKind::Project
| ResourceKind::CatalogModel
| ResourceKind::Identity
| ResourceKind::Price
) {
continue;
}
assert!(
kind.permits(&ResourceScope::Tenant(tenant)) && kind.permits(&project),
"{} must be tenant- or project-scoped",
kind.as_str()
);
assert!(
!kind.permits(&ResourceScope::Deployment),
"{} must not be deployment-wide",
kind.as_str()
);
}
}
#[test]
fn a_scope_names_its_tenant() {
let tenant = tenant_id(1);
assert_eq!(ResourceScope::Deployment.tenant(), None);
assert_eq!(ResourceScope::Tenant(tenant).tenant(), Some(tenant));
assert_eq!(
ResourceScope::Project {
tenant,
project: ProjectId::new(Uuid7::from_parts(2, 0, 2).unwrap()),
}
.tenant(),
Some(tenant)
);
}
#[test]
fn version_numbers_start_at_one_and_only_climb() {
assert_eq!(ResourceVersionNumber::new(0), None);
assert_eq!(
ResourceVersionNumber::new(1),
Some(ResourceVersionNumber::FIRST)
);
assert_eq!(ResourceVersionNumber::FIRST.next().get(), 2);
assert!(ResourceVersionNumber::FIRST < ResourceVersionNumber::FIRST.next());
assert_eq!(ResourceVersionNumber::FIRST.to_string(), "v1");
}
#[test]
fn a_reference_names_a_version_and_a_resource() {
let first = reference(ResourceKind::Alias, 1);
let second = first.at(ResourceVersionNumber::FIRST.next());
assert!(first.same_resource(&second));
assert_ne!(first, second);
assert!(first < second, "versions of one resource sort in order");
assert!(!first.same_resource(&reference(ResourceKind::Alias, 2)));
assert!(!first.same_resource(&ResourceRef {
kind: ResourceKind::Policy,
..first
}));
assert_eq!(
first.to_string(),
format!("alias/{}@v1", first.id),
"a reference is greppable in a log line"
);
}
#[test]
fn references_of_different_kinds_never_canonicalize_alike() {
let alias = reference(ResourceKind::Alias, 1);
let policy = ResourceRef {
kind: ResourceKind::Policy,
..alias
};
assert_ne!(
alias.checksum().unwrap(),
policy.checksum().unwrap(),
"the kind participates in the canonical form"
);
}
#[test]
fn dependency_order_does_not_change_a_resource_checksum() {
let base = ResourceVersion::new(
reference(ResourceKind::Alias, 1),
ResourceScope::Tenant(tenant_id(9)),
Slug::parse("fast").unwrap(),
ResourceBody::Inline(CanonicalValue::map([(
"targets",
CanonicalValue::List(vec![CanonicalValue::string("primary")]),
)])),
);
let ascending = base.clone().depending_on([
reference(ResourceKind::ProviderCredential, 2),
reference(ResourceKind::Provider, 3),
]);
let descending = base.clone().depending_on([
reference(ResourceKind::Provider, 3),
reference(ResourceKind::ProviderCredential, 2),
]);
assert_eq!(
ascending.content_checksum().unwrap(),
descending.content_checksum().unwrap()
);
assert_ne!(
base.content_checksum().unwrap(),
ascending.content_checksum().unwrap()
);
}
#[test]
fn renaming_changes_the_content_but_not_the_identity() {
let reference = reference(ResourceKind::Alias, 1);
let body = ResourceBody::Inline(CanonicalValue::Bool(true));
let before = ResourceVersion::new(
reference,
ResourceScope::Tenant(tenant_id(9)),
Slug::parse("fast").unwrap(),
body.clone(),
);
let renamed = ResourceVersion {
slug: Slug::parse("quick").unwrap(),
reference: reference.at(ResourceVersionNumber::FIRST.next()),
..before.clone()
};
assert!(before.reference.same_resource(&renamed.reference));
assert_ne!(
before.content_checksum().unwrap(),
renamed.content_checksum().unwrap()
);
}
#[test]
fn a_blob_reference_verifies_its_payload() {
let payload = b"{\"models\":[]}".repeat(64);
let reference = BlobRef::of(BlobKind::CatalogSnapshot, &payload);
assert_eq!(reference.size_bytes, payload.len() as u64);
reference
.verify(&payload)
.expect("the payload it addresses");
assert_eq!(reference, BlobRef::of(BlobKind::CatalogSnapshot, &payload));
assert_ne!(reference, BlobRef::of(BlobKind::PriceBook, &payload));
}
#[test]
fn a_substituted_or_truncated_blob_is_refused() {
let payload = b"catalogue".to_vec();
let reference = BlobRef::of(BlobKind::CatalogSnapshot, &payload);
let truncated = &payload[..payload.len() - 1];
assert!(matches!(
reference.verify(truncated),
Err(BlobError::Size {
expected_bytes: 9,
actual_bytes: 8,
..
})
));
let substituted = b"catalogxes".to_vec();
let error = reference
.verify(&substituted[..9])
.expect_err("same length, different bytes");
assert!(matches!(error, BlobError::Digest { .. }));
assert!(error.to_string().contains("hashes to"));
}
#[test]
fn an_inline_body_never_canonicalizes_like_a_blob() {
let payload = b"snapshot".to_vec();
let blob = BlobRef::of(BlobKind::CatalogSnapshot, &payload);
let as_blob = ResourceBody::Blob(blob);
let as_inline = ResourceBody::Inline(blob.canonical());
assert_ne!(
as_blob.canonical().checksum().unwrap(),
as_inline.canonical().checksum().unwrap()
);
assert_eq!(as_blob.blob(), Some(&blob));
assert_eq!(as_inline.blob(), None);
}
}