use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use engenho_kube_client::{Connection, KubeUrlBuilder, Kubeconfig};
use engenho_store::{ImageTripwire, InProcessRouter, StoreMesh, default_config};
use serde_json::Value;
use super::{CensusError, DiscoveryEntry, Gvk, Shape, text_at};
use crate::runtime::STORE_DIR;
#[async_trait]
pub trait Source: Send + Sync {
async fn list(&self, gvk: &Gvk) -> Result<Vec<Value>, CensusError>;
async fn kinds(&self) -> Result<Vec<Gvk>, CensusError>;
async fn image(&self) -> Option<ImageTripwire>;
fn label(&self) -> String;
}
pub struct ApiSource {
conn: Connection,
served: BTreeMap<Gvk, Served>,
judged_at: BTreeMap<(String, String), String>,
}
const METHOD_NOT_ALLOWED: u16 = 405;
#[derive(Debug, Clone)]
struct Served {
plural: String,
}
#[derive(Debug, Clone, Copy)]
enum ApiPath<'a> {
Core,
CoreVersion(&'a str),
Groups,
GroupVersion { group: &'a str, version: &'a str },
List { gvk: &'a Gvk, plural: &'a str },
}
impl fmt::Display for ApiPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Core => f.write_str("/api"),
Self::CoreVersion(version) => write!(f, "/api/{version}"),
Self::Groups => f.write_str("/apis"),
Self::GroupVersion { group, version } => write!(f, "/apis/{group}/{version}"),
Self::List { gvk, plural } if gvk.group.is_empty() => {
write!(f, "/api/{}/{plural}", gvk.version)
}
Self::List { gvk, plural } => {
write!(f, "/apis/{}/{}/{plural}", gvk.group, gvk.version)
}
}
}
}
struct Url<'a> {
server: &'a str,
path: ApiPath<'a>,
}
impl fmt::Display for Url<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.server, self.path)
}
}
struct Fetched {
url: String,
body: Value,
}
impl Fetched {
fn shape(&self, shape: Shape) -> CensusError {
CensusError::Shape {
url: self.url.clone(),
shape,
}
}
fn array<'a>(
&self,
within: &'a Value,
key: &str,
shape: Shape,
) -> Result<&'a Vec<Value>, CensusError> {
within
.get(key)
.and_then(Value::as_array)
.ok_or_else(|| self.shape(shape))
}
fn named<'a>(
&self,
entry: &'a Value,
name_of: impl FnOnce(&'a Value) -> Option<&'a str>,
what: DiscoveryEntry,
) -> Result<&'a str, CensusError> {
name_of(entry).ok_or_else(|| self.shape(Shape::Unnamed(what)))
}
}
fn by_preference<'a>(preferred: Option<&str>, versions: &[&'a str]) -> Vec<&'a str> {
let (first, rest): (Vec<&str>, Vec<&str>) =
versions.iter().partition(|v| Some(**v) == preferred);
first.into_iter().chain(rest).collect()
}
impl ApiSource {
pub async fn from_kubeconfig(path: &Path) -> Result<Self, CensusError> {
let conn = Kubeconfig::load(path)?.resolve_connection()?;
Self::connect(conn).await
}
pub async fn connect(conn: Connection) -> Result<Self, CensusError> {
let mut source = Self {
conn,
served: BTreeMap::new(),
judged_at: BTreeMap::new(),
};
let core = source.get(ApiPath::Core, None).await?;
for version in core.array(&core.body, "versions", Shape::NoDiscovery)? {
let version = core.named(version, Value::as_str, DiscoveryEntry::CoreVersion)?;
let list = source.get(ApiPath::CoreVersion(version), None).await?;
source.learn("", version, &list)?;
}
let groups = source.get(ApiPath::Groups, None).await?;
for group in groups.array(&groups.body, "groups", Shape::NoDiscovery)? {
let name = groups.named(group, |g| text_at(g, "/name"), DiscoveryEntry::Group)?;
let versions = groups
.array(group, "versions", Shape::NoDiscovery)?
.iter()
.map(|v| groups.named(v, |v| text_at(v, "/version"), DiscoveryEntry::GroupVersion))
.collect::<Result<Vec<_>, _>>()?;
let preferred = text_at(group, "/preferredVersion/version");
for version in by_preference(preferred, &versions) {
let list = source
.get(
ApiPath::GroupVersion {
group: name,
version,
},
None,
)
.await?;
source.learn(name, version, &list)?;
}
}
Ok(source)
}
fn learn(&mut self, group: &str, version: &str, list: &Fetched) -> Result<(), CensusError> {
for resource in list.array(&list.body, "resources", Shape::NoDiscovery)? {
let plural = list.named(resource, |r| text_at(r, "/name"), DiscoveryEntry::Resource)?;
let listable = resource
.get("verbs")
.and_then(Value::as_array)
.is_some_and(|verbs| verbs.iter().any(|v| v.as_str() == Some("list")));
if plural.contains('/') || !listable {
continue;
}
let kind = list.named(resource, |r| text_at(r, "/kind"), DiscoveryEntry::Resource)?;
self.served.insert(
Gvk::new(group, version, kind),
Served {
plural: plural.to_owned(),
},
);
self.judged_at
.entry((group.to_owned(), kind.to_owned()))
.or_insert_with(|| version.to_owned());
}
Ok(())
}
async fn get(&self, path: ApiPath<'_>, page: Option<&str>) -> Result<Fetched, CensusError> {
let base = Url {
server: self.conn.server(),
path,
}
.to_string();
let url = KubeUrlBuilder::new(&base)?
.opt_pair("continue", page)
.finish();
let request = self.conn.auth_header(self.conn.http().get(&url))?;
let response = request.send().await.map_err(|e| CensusError::Http {
url: url.clone(),
source: Box::new(e),
})?;
let status = response.status();
if !status.is_success() {
return Err(CensusError::Status {
url,
code: status.as_u16(),
});
}
let bytes = response.bytes().await.map_err(|e| CensusError::Http {
url: url.clone(),
source: Box::new(e),
})?;
match serde_json::from_slice(&bytes) {
Ok(body) => Ok(Fetched { url, body }),
Err(source) => Err(CensusError::Decode { url, source }),
}
}
}
#[async_trait]
impl Source for ApiSource {
async fn list(&self, gvk: &Gvk) -> Result<Vec<Value>, CensusError> {
let served = self
.served
.get(gvk)
.ok_or_else(|| CensusError::KindNotServed { gvk: gvk.clone() })?;
let path = ApiPath::List {
gvk,
plural: &served.plural,
};
let mut items = Vec::new();
let mut page: Option<String> = None;
loop {
let mut fetched = match self.get(path, page.as_deref()).await {
Ok(fetched) => fetched,
Err(CensusError::Status {
url,
code: METHOD_NOT_ALLOWED,
}) => {
return Err(CensusError::NotListable {
gvk: gvk.clone(),
url,
});
}
Err(e) => return Err(e),
};
let Some(Value::Array(batch)) = fetched.body.get_mut("items").map(Value::take) else {
return Err(CensusError::Shape {
url: fetched.url,
shape: Shape::NoItems,
});
};
items.extend(batch);
page = text_at(&fetched.body, "/metadata/continue")
.filter(|token| !token.is_empty())
.map(str::to_owned);
if page.is_none() {
return Ok(items);
}
}
}
async fn kinds(&self) -> Result<Vec<Gvk>, CensusError> {
Ok(self
.judged_at
.iter()
.map(|((group, kind), version)| Gvk::new(group, version, kind))
.collect())
}
async fn image(&self) -> Option<ImageTripwire> {
None
}
fn label(&self) -> String {
["apiserver ", self.conn.server()].concat()
}
}
const CENSUS_CLUSTER: &str = "engenho-census";
pub struct DataDirSource {
mesh: StoreMesh,
scratch: Scratch,
origin: PathBuf,
}
impl DataDirSource {
pub async fn open(data_dir: &Path) -> Result<Self, CensusError> {
let store = data_dir.join(STORE_DIR);
let scratch = Scratch::copy_of(&store)?;
let mesh = StoreMesh::start_durable(
1,
"in-process://census".to_owned(),
InProcessRouter::new(),
default_config(CENSUS_CLUSTER)?,
scratch.store(),
)
.await?;
if !mesh.is_initialized().await {
mesh.terminate().await?;
return Err(CensusError::EmptyStore {
path: data_dir.to_owned(),
});
}
Ok(Self {
mesh,
scratch,
origin: data_dir.to_owned(),
})
}
pub async fn close(self) -> Result<(), CensusError> {
let Self { mesh, scratch, .. } = self;
let stopped = mesh.terminate().await;
drop(scratch);
Ok(stopped?)
}
}
#[async_trait]
impl Source for DataDirSource {
async fn list(&self, gvk: &Gvk) -> Result<Vec<Value>, CensusError> {
Ok(self
.mesh
.list(&gvk.group, &gvk.version, &gvk.kind, None)
.await
.into_iter()
.map(|(_, v)| v)
.collect())
}
async fn kinds(&self) -> Result<Vec<Gvk>, CensusError> {
let mut kinds: Vec<Gvk> = Vec::new();
self.mesh
.for_each_resource(|key, _, _| {
let seen = kinds.last().is_some_and(|last| {
last.group == key.group && last.version == key.version && last.kind == key.kind
});
if !seen {
kinds.push(Gvk::new(&key.group, &key.version, &key.kind));
}
})
.await;
Ok(kinds)
}
async fn image(&self) -> Option<ImageTripwire> {
self.mesh.image_tripwire().await
}
fn label(&self) -> String {
["data directory ", &self.origin.display().to_string()].concat()
}
}
pub(super) struct Scratch {
root: PathBuf,
}
struct ScratchName {
nonce: [u8; 16],
}
impl fmt::Display for ScratchName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("engenho-census-")?;
self.nonce.iter().try_for_each(|b| write!(f, "{b:02x}"))
}
}
impl Scratch {
pub(super) fn copy_of(store: &Path) -> Result<Self, CensusError> {
std::fs::read_dir(store).map_err(|source| CensusError::NoStore {
path: store.to_owned(),
source,
})?;
let mut name = ScratchName { nonce: [0; 16] };
getrandom::fill(&mut name.nonce).map_err(CensusError::Entropy)?;
let root = std::env::temp_dir().join(name.to_string());
create_private_dir(&root).map_err(|source| CensusError::Copy {
from: store.to_owned(),
to: root.clone(),
source,
})?;
let scratch = Self { root };
copy_tree(store, &scratch.store())?;
Ok(scratch)
}
pub(super) fn store(&self) -> PathBuf {
self.root.join(STORE_DIR)
}
#[cfg(test)]
pub(super) fn root(&self) -> &Path {
&self.root
}
}
#[cfg(unix)]
const PRIVATE_DIR_MODE: u32 = 0o700;
#[cfg(unix)]
pub(super) fn create_private_dir(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.mode(PRIVATE_DIR_MODE)
.create(path)
}
#[cfg(not(unix))]
pub(super) fn create_private_dir(path: &Path) -> std::io::Result<()> {
std::fs::DirBuilder::new().create(path)
}
impl Drop for Scratch {
fn drop(&mut self) {
if let Err(error) = std::fs::remove_dir_all(&self.root) {
tracing::warn!(
path = %self.root.display(),
%error,
"census: could not delete the private store copy"
);
}
}
}
fn copy_tree(from: &Path, to: &Path) -> Result<(), CensusError> {
let copy_err = |source| CensusError::Copy {
from: from.to_owned(),
to: to.to_owned(),
source,
};
create_private_dir(to).map_err(copy_err)?;
for entry in std::fs::read_dir(from).map_err(copy_err)? {
let entry = entry.map_err(copy_err)?;
let kind = entry.file_type().map_err(copy_err)?;
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if kind.is_dir() {
copy_tree(&src, &dst)?;
} else if kind.is_file() {
std::fs::copy(&src, &dst).map_err(|source| CensusError::Copy {
from: src.clone(),
to: dst.clone(),
source,
})?;
} else {
return Err(CensusError::UncopyableEntry { path: src });
}
}
Ok(())
}