use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectRef {
pub api_version: String,
pub kind: String,
pub namespace: String,
pub name: String,
}
impl ObjectRef {
pub fn from_manifest(manifest: &Value) -> Result<Self, K8sClusterError> {
let field = |path: &[&str]| -> Result<String, K8sClusterError> {
let mut cur = manifest;
for p in path {
cur = cur.get(p).ok_or_else(|| {
K8sClusterError::InvalidManifest(format!(
"manifest is missing `{}`",
path.join(".")
))
})?;
}
cur.as_str().map(str::to_string).ok_or_else(|| {
K8sClusterError::InvalidManifest(format!("`{}` is not a string", path.join(".")))
})
};
Ok(Self {
api_version: field(&["apiVersion"])?,
kind: field(&["kind"])?,
namespace: field(&["metadata", "namespace"])?,
name: field(&["metadata", "name"])?,
})
}
}
impl std::fmt::Display for ObjectRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}/{} {}/{}",
self.api_version, self.kind, self.namespace, self.name
)
}
}
#[derive(Debug, Error)]
pub enum K8sClusterError {
#[error(
"no Kubernetes API client is configured for the K8s deployer env-pack; \
the typed cluster client ships in the Phase D K8s apply PR — until then \
K8s provider verbs cannot run"
)]
Unconfigured,
#[error("invalid manifest: {0}")]
InvalidManifest(String),
#[error("Kubernetes API error: {0}")]
Api(String),
}
#[async_trait]
pub trait K8sCluster: std::fmt::Debug + Send + Sync {
async fn apply(&self, manifest: &Value) -> Result<(), K8sClusterError>;
async fn delete(&self, object: &ObjectRef) -> Result<(), K8sClusterError>;
}
#[derive(Debug, Default)]
pub struct UnconfiguredCluster;
#[async_trait]
impl K8sCluster for UnconfiguredCluster {
async fn apply(&self, _manifest: &Value) -> Result<(), K8sClusterError> {
Err(K8sClusterError::Unconfigured)
}
async fn delete(&self, _object: &ObjectRef) -> Result<(), K8sClusterError> {
Err(K8sClusterError::Unconfigured)
}
}
#[cfg(test)]
#[derive(Debug, Default)]
pub struct InMemoryCluster {
objects: std::sync::Mutex<std::collections::BTreeMap<ObjectRef, Value>>,
}
#[cfg(test)]
impl InMemoryCluster {
pub fn objects(&self) -> std::collections::BTreeMap<ObjectRef, Value> {
self.objects.lock().expect("mutex not poisoned").clone()
}
}
#[cfg(test)]
#[async_trait]
impl K8sCluster for InMemoryCluster {
async fn apply(&self, manifest: &Value) -> Result<(), K8sClusterError> {
let object = ObjectRef::from_manifest(manifest)?;
self.objects
.lock()
.expect("mutex not poisoned")
.insert(object, manifest.clone());
Ok(())
}
async fn delete(&self, object: &ObjectRef) -> Result<(), K8sClusterError> {
self.objects
.lock()
.expect("mutex not poisoned")
.remove(object);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn manifest() -> Value {
json!({
"apiVersion": "v1",
"kind": "Service",
"metadata": {"name": "svc-a", "namespace": "ns-a"},
})
}
#[test]
fn object_ref_extracts_identity_from_manifest() {
let r = ObjectRef::from_manifest(&manifest()).unwrap();
assert_eq!(
r,
ObjectRef {
api_version: "v1".into(),
kind: "Service".into(),
namespace: "ns-a".into(),
name: "svc-a".into(),
}
);
}
#[test]
fn object_ref_rejects_manifest_without_namespace() {
let m = json!({"apiVersion": "v1", "kind": "Service", "metadata": {"name": "x"}});
let err = ObjectRef::from_manifest(&m).unwrap_err();
assert!(
matches!(err, K8sClusterError::InvalidManifest(ref msg) if msg.contains("metadata.namespace")),
"got {err:?}"
);
}
#[tokio::test]
async fn unconfigured_cluster_fails_both_verbs() {
let c = UnconfiguredCluster;
assert!(matches!(
c.apply(&manifest()).await.unwrap_err(),
K8sClusterError::Unconfigured
));
let r = ObjectRef::from_manifest(&manifest()).unwrap();
assert!(matches!(
c.delete(&r).await.unwrap_err(),
K8sClusterError::Unconfigured
));
}
#[tokio::test]
async fn in_memory_cluster_upserts_and_deletes_idempotently() {
let c = InMemoryCluster::default();
c.apply(&manifest()).await.unwrap();
c.apply(&manifest()).await.unwrap();
assert_eq!(c.objects().len(), 1, "apply is an upsert");
let r = ObjectRef::from_manifest(&manifest()).unwrap();
c.delete(&r).await.unwrap();
c.delete(&r).await.unwrap();
assert!(c.objects().is_empty(), "delete of absent is Ok");
}
}