use std::time::SystemTime;
use async_trait::async_trait;
use super::{BackendFailure, BackendKind, Capabilities, FailureCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ControlPlaneBackend {
#[default]
Postgres,
}
impl<'de> serde::Deserialize<'de> for ControlPlaneBackend {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
Self::parse(&name).map_err(serde::de::Error::custom)
}
}
impl ControlPlaneBackend {
pub const fn kind(self) -> BackendKind {
match self {
Self::Postgres => BackendKind::Postgres,
}
}
pub fn parse(name: &str) -> Result<Self, UnsupportedControlPlaneBackend> {
match name {
"postgres" => Ok(Self::Postgres),
"postgresql" | "pg" => Err(UnsupportedControlPlaneBackend::Unknown {
name: name.to_owned(),
}),
"redis" => Err(UnsupportedControlPlaneBackend::HotStateOnly {
name: "redis".to_owned(),
}),
"memory" | "in-memory" => Err(UnsupportedControlPlaneBackend::NotDurable {
name: name.to_owned(),
}),
other => Err(UnsupportedControlPlaneBackend::Unknown {
name: other.to_owned(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UnsupportedControlPlaneBackend {
#[error(
"`{name}` holds loss-tolerant hot state and cannot own durable control-plane state; \
the only durable control-plane backend is `postgres`"
)]
HotStateOnly { name: String },
#[error("`{name}` is not durable and cannot own control-plane state")]
NotDurable { name: String },
#[error("unknown control-plane backend `{name}`; the only durable backend is `postgres`")]
Unknown { name: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RevisionId(pub u64);
impl std::fmt::Display for RevisionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "r{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResourceId(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ResourceKind {
Tenant,
Project,
Identity,
Provider,
ProviderCredential,
CatalogModel,
ModelEnablement,
Price,
Alias,
Policy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceVersionRef {
pub kind: ResourceKind,
pub id: ResourceId,
pub slug: String,
pub version: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RevisionChecksum(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Actor {
Human { issuer: String, subject: String },
Breakglass,
System { component: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditEvent {
pub actor: Actor,
pub action: String,
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct IdempotencyKey(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpectedRevision {
Empty,
Exactly(RevisionId),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevisionCandidate {
pub expected: ExpectedRevision,
pub resources: Vec<ResourceVersionRef>,
pub checksum: RevisionChecksum,
pub audit: AuditEvent,
pub idempotency_key: IdempotencyKey,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevisionManifest {
pub id: RevisionId,
pub parent: Option<RevisionId>,
pub created_at: SystemTime,
pub resources: Vec<ResourceVersionRef>,
pub checksum: RevisionChecksum,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ControlPlaneError {
#[error("control-plane store `{backend}` unavailable: {message}")]
Unavailable {
backend: &'static str,
message: String,
},
#[error("expected revision {expected:?} but the newest is {actual:?}")]
Conflict {
expected: ExpectedRevision,
actual: Option<RevisionId>,
},
#[error("revision {0} is not retained")]
RevisionNotFound(RevisionId),
#[error("invalid candidate revision: {0}")]
Invalid(String),
#[error(
"idempotency key `{}` already published revision {published} with different desired state",
key.0
)]
IdempotencyKeyReused {
key: IdempotencyKey,
published: RevisionId,
},
#[error("control-plane store `{backend}` refused the operation: {message}")]
Denied {
backend: &'static str,
message: String,
},
#[error("stored revision {revision} is unreadable: {message}")]
Corrupt {
revision: RevisionId,
message: String,
},
}
impl BackendFailure for ControlPlaneError {
fn category(&self) -> FailureCategory {
match self {
Self::Unavailable { .. } => FailureCategory::Unavailable,
Self::Conflict { .. } => FailureCategory::Conflict,
Self::RevisionNotFound(_) => FailureCategory::NotFound,
Self::Invalid(_) | Self::IdempotencyKeyReused { .. } => FailureCategory::Invalid,
Self::Denied { .. } => FailureCategory::Denied,
Self::Corrupt { .. } => FailureCategory::Corrupt,
}
}
}
#[async_trait]
pub trait ControlPlaneStore: Send + Sync {
fn name(&self) -> &'static str;
fn capabilities(&self) -> Capabilities;
async fn health(&self) -> Result<(), ControlPlaneError>;
async fn desired_revision(&self) -> Result<Option<RevisionId>, ControlPlaneError>;
async fn load_revision(&self, id: RevisionId) -> Result<RevisionManifest, ControlPlaneError>;
async fn publish_revision(
&self,
candidate: RevisionCandidate,
) -> Result<RevisionManifest, ControlPlaneError>;
async fn audit_trail(&self, id: RevisionId) -> Result<Vec<AuditEvent>, ControlPlaneError>;
}
#[cfg(test)]
mod tests {
use super::super::fakes::{InMemoryControlPlane, audit, candidate};
use super::*;
#[test]
fn redis_cannot_be_selected_as_a_control_plane_backend() {
let error = ControlPlaneBackend::parse("redis").expect_err("redis must be refused");
assert!(matches!(
error,
UnsupportedControlPlaneBackend::HotStateOnly { .. }
));
assert!(error.to_string().contains("hot state"));
assert!(matches!(
ControlPlaneBackend::parse("in-memory"),
Err(UnsupportedControlPlaneBackend::NotDurable { .. })
));
assert!(matches!(
ControlPlaneBackend::parse("sqlite"),
Err(UnsupportedControlPlaneBackend::Unknown { .. })
));
}
#[test]
fn every_selectable_control_plane_backend_is_durable() {
let backend = ControlPlaneBackend::parse("postgres").expect("durable backend");
assert!(backend.kind().durable_control_plane());
assert_eq!(
ControlPlaneBackend::default(),
ControlPlaneBackend::Postgres
);
}
#[test]
fn deserialization_resolves_through_parse() {
assert_eq!(
serde_json::from_str::<ControlPlaneBackend>("\"postgres\"").unwrap(),
ControlPlaneBackend::Postgres
);
for name in ["redis", "in-memory", "postgresql", "sqlite"] {
let refusal = serde_json::from_str::<ControlPlaneBackend>(&format!("\"{name}\""))
.expect_err("only postgres is a durable control plane")
.to_string();
let expected = ControlPlaneBackend::parse(name).unwrap_err().to_string();
assert!(
refusal.contains(&expected),
"`{name}` was refused as `{refusal}` instead of `{expected}`"
);
}
}
#[tokio::test]
async fn publication_is_a_chain_of_immutable_revisions() {
let store = InMemoryControlPlane::new();
assert_eq!(store.desired_revision().await.unwrap(), None);
let first = store
.publish_revision(candidate(ExpectedRevision::Empty, "first", "a"))
.await
.expect("first publication");
assert_eq!(first.parent, None);
assert_eq!(store.desired_revision().await.unwrap(), Some(first.id));
let second = store
.publish_revision(candidate(
ExpectedRevision::Exactly(first.id),
"second",
"b",
))
.await
.expect("second publication");
assert_eq!(second.parent, Some(first.id));
assert!(second.id > first.id);
assert_eq!(store.load_revision(first.id).await.unwrap(), first);
}
#[tokio::test]
async fn a_stale_expected_revision_conflicts_instead_of_overwriting() {
let store = InMemoryControlPlane::new();
let first = store
.publish_revision(candidate(ExpectedRevision::Empty, "first", "a"))
.await
.unwrap();
let error = store
.publish_revision(candidate(ExpectedRevision::Empty, "racing", "c"))
.await
.expect_err("a stale expectation must not publish");
assert_eq!(
error,
ControlPlaneError::Conflict {
expected: ExpectedRevision::Empty,
actual: Some(first.id),
}
);
assert_eq!(error.category(), FailureCategory::Conflict);
assert!(!error.retryable());
assert_eq!(store.desired_revision().await.unwrap(), Some(first.id));
}
#[tokio::test]
async fn a_retried_publication_applies_once() {
let store = InMemoryControlPlane::new();
let candidate = candidate(ExpectedRevision::Empty, "first", "a");
let first = store.publish_revision(candidate.clone()).await.unwrap();
let retried = store
.publish_revision(candidate)
.await
.expect("a retry replays the original outcome");
assert_eq!(first, retried);
assert_eq!(store.published_revisions(), 1);
}
#[tokio::test]
async fn a_reused_key_carrying_different_state_is_refused() {
let store = InMemoryControlPlane::new();
let first = store
.publish_revision(candidate(ExpectedRevision::Empty, "first", "a"))
.await
.unwrap();
let mut reused = candidate(ExpectedRevision::Exactly(first.id), "second", "a");
reused.checksum = RevisionChecksum("sha256:b".to_owned());
let error = store
.publish_revision(reused)
.await
.expect_err("a reused key must not replay a different revision");
assert_eq!(
error,
ControlPlaneError::IdempotencyKeyReused {
key: IdempotencyKey("a".to_owned()),
published: first.id,
}
);
assert_eq!(error.category(), FailureCategory::Invalid);
assert!(!error.retryable());
assert_eq!(store.published_revisions(), 1);
assert_eq!(store.desired_revision().await.unwrap(), Some(first.id));
}
#[tokio::test]
async fn a_replay_survives_a_moved_expectation() {
let store = InMemoryControlPlane::new();
let first = store
.publish_revision(candidate(ExpectedRevision::Empty, "first", "a"))
.await
.unwrap();
store
.publish_revision(candidate(
ExpectedRevision::Exactly(first.id),
"second",
"b",
))
.await
.unwrap();
let replayed = store
.publish_revision(candidate(ExpectedRevision::Empty, "first", "a"))
.await
.expect("an unchanged retry replays its own outcome");
assert_eq!(replayed, first);
assert_eq!(store.published_revisions(), 2);
}
#[tokio::test]
async fn audit_is_written_with_the_mutation() {
let store = InMemoryControlPlane::new();
let revision = store
.publish_revision(candidate(ExpectedRevision::Empty, "first", "a"))
.await
.unwrap();
assert_eq!(
store.audit_trail(revision.id).await.unwrap(),
vec![audit("first")]
);
}
#[tokio::test]
async fn an_audit_actor_round_trips_from_owned_data() {
let store = InMemoryControlPlane::new();
let read_back = |column: &str| Actor::System {
component: column.to_string(),
};
let mut candidate = candidate(ExpectedRevision::Empty, "refresh", "a");
candidate.audit.actor = read_back(&String::from("catalog-refresh"));
let revision = store.publish_revision(candidate).await.unwrap();
let trail = store.audit_trail(revision.id).await.unwrap();
assert_eq!(trail[0].actor, read_back("catalog-refresh"));
assert_ne!(trail[0].actor, read_back("someone-else"));
}
#[tokio::test]
async fn a_rejected_candidate_leaves_no_trace() {
let store = InMemoryControlPlane::new();
let mut invalid = candidate(ExpectedRevision::Empty, "invalid", "a");
invalid.resources.clear();
let error = store
.publish_revision(invalid)
.await
.expect_err("an empty candidate is invalid");
assert_eq!(error.category(), FailureCategory::Invalid);
assert_eq!(store.desired_revision().await.unwrap(), None);
assert_eq!(store.published_revisions(), 0);
}
#[tokio::test]
async fn unknown_revisions_and_outages_are_distinguishable() {
let store = InMemoryControlPlane::new();
let missing = store
.load_revision(RevisionId(7))
.await
.expect_err("unpublished revision");
assert_eq!(missing.category(), FailureCategory::NotFound);
assert!(!missing.retryable());
store.set_unavailable(true);
let outage = store
.desired_revision()
.await
.expect_err("an unreachable store must not report an empty control plane");
assert_eq!(outage.category(), FailureCategory::Unavailable);
assert!(outage.retryable());
}
#[tokio::test]
async fn the_store_declares_the_capabilities_publication_relies_on() {
use super::super::Capability;
let store = InMemoryControlPlane::new();
for capability in [
Capability::TransactionalWrites,
Capability::OptimisticConcurrency,
Capability::IdempotentWrites,
Capability::TransactionalAudit,
] {
assert!(
store.capabilities().has(capability),
"{capability:?} is required of every ControlPlaneStore"
);
}
store.health().await.expect("a healthy fake");
}
}