mod predicates;
mod rbac;
mod source;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use engenho_apiserver::schema_validation::SchemaViolation;
use engenho_apiserver::validation::Violation;
use engenho_scheduler::filter::Rejection;
use engenho_store::{ImageInconsistency, StoreError};
use engenho_types::error::KubeError;
use serde_json::Value;
pub use rbac::{Dim, GrantedResource, LostGrant, SubjectRef, Via};
pub use source::{ApiSource, DataDirSource, Source};
macro_rules! census_predicates {
($( $(#[$doc:meta])* $variant:ident => $name:literal, $row:literal, $summary:literal; )+) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Predicate {
$( $(#[$doc])* $variant, )+
}
impl Predicate {
pub const ALL: &'static [Self] = &[ $( Self::$variant, )+ ];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
$( Self::$variant => $name, )+
}
}
#[must_use]
pub const fn plan_row(self) -> &'static str {
match self {
$( Self::$variant => $row, )+
}
}
#[must_use]
pub const fn summary(self) -> &'static str {
match self {
$( Self::$variant => $summary, )+
}
}
}
};
}
census_predicates! {
NodeOvercommitted => "node-overcommitted", "T1.5",
"nodes the capacity ledger charges past their allocatable";
BoundPodRejected => "bound-pod-rejected", "T5.7",
"bound pods a filter plugin would refuse on their own node";
DeploymentWouldRoll => "deployment-would-roll", "T4.10",
"Deployments whose template no owned ReplicaSet runs";
StoredWouldReject => "stored-would-reject", "T4.4/T4.5",
"stored objects defaulting + validation + CRD schema would refuse";
StoreImageInconsistent => "store-image-inconsistent", "T3.4",
"ways the store's boot found its durable image disagreeing with itself";
RbacBareParentOnly => "rbac-bare-parent-only", "T4.2",
"subjects that reached a subresource only through a bare parent";
}
impl Predicate {
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|p| p.name() == name)
}
}
impl fmt::Display for Predicate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy)]
pub struct Catalog;
impl fmt::Display for Catalog {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for p in Predicate::ALL {
writeln!(f, "{:<26} {:<10} {}", p.name(), p.plan_row(), p.summary())?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Gvk {
pub group: String,
pub version: String,
pub kind: String,
}
impl Gvk {
#[must_use]
pub fn new(group: &str, version: &str, kind: &str) -> Self {
Self {
group: group.to_owned(),
version: version.to_owned(),
kind: kind.to_owned(),
}
}
}
impl fmt::Display for Gvk {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.group.is_empty() {
write!(f, "{}/{}", self.version, self.kind)
} else {
write!(f, "{}/{}/{}", self.group, self.version, self.kind)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Builtin {
Node,
Pod,
Lease,
Deployment,
ReplicaSet,
ClusterRole,
ClusterRoleBinding,
Role,
RoleBinding,
CustomResourceDefinition,
}
impl Builtin {
fn gvk(self) -> Gvk {
let (group, version, kind) = match self {
Self::Node => ("", "v1", "Node"),
Self::Pod => ("", "v1", "Pod"),
Self::Lease => ("coordination.k8s.io", "v1", "Lease"),
Self::Deployment => ("apps", "v1", "Deployment"),
Self::ReplicaSet => ("apps", "v1", "ReplicaSet"),
Self::ClusterRole => (RBAC_GROUP, "v1", "ClusterRole"),
Self::ClusterRoleBinding => (RBAC_GROUP, "v1", "ClusterRoleBinding"),
Self::Role => (RBAC_GROUP, "v1", "Role"),
Self::RoleBinding => (RBAC_GROUP, "v1", "RoleBinding"),
Self::CustomResourceDefinition => {
("apiextensions.k8s.io", "v1", "CustomResourceDefinition")
}
};
Gvk::new(group, version, kind)
}
}
const RBAC_GROUP: &str = "rbac.authorization.k8s.io";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Subject {
Object {
gvk: Gvk,
namespace: Option<String>,
name: Option<String>,
},
StoreImage,
Rbac(SubjectRef),
}
impl Subject {
fn object(gvk: &Gvk, object: &Value) -> Self {
Self::Object {
gvk: gvk.clone(),
namespace: text_at(object, "/metadata/namespace").map(str::to_owned),
name: text_at(object, "/metadata/name").map(str::to_owned),
}
}
fn group(&self) -> SubjectGroup<'_> {
match self {
Self::Object { gvk, .. } => SubjectGroup::Kind(gvk),
Self::StoreImage => SubjectGroup::StoreImage,
Self::Rbac(_) => SubjectGroup::RbacSubject,
}
}
}
impl fmt::Display for Subject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Object {
gvk,
namespace,
name,
} => {
write!(f, "{gvk} ")?;
if let Some(ns) = namespace {
write!(f, "{ns}/")?;
}
f.write_str(name.as_deref().unwrap_or("(unnamed)"))
}
Self::StoreImage => f.write_str("store-image"),
Self::Rbac(subject) => write!(f, "{subject}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum SubjectGroup<'a> {
Kind(&'a Gvk),
StoreImage,
RbacSubject,
}
impl fmt::Display for SubjectGroup<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Kind(gvk) => write!(f, "{gvk}"),
Self::StoreImage => f.write_str("store-image"),
Self::RbacSubject => f.write_str("rbac-subject"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Finding {
Overcommitted {
cpu_milli: i128,
mem_milli: i128,
},
Rejected(Rejection),
NodeNotObserved {
node: String,
},
NoReplicaSetRunsTemplate,
Invalid(Violation),
SchemaInvalid(SchemaViolation),
ImageInconsistent {
kind: ImageInconsistency,
hits: u64,
},
SubresourceLost(LostGrant),
}
impl Finding {
fn reason(&self) -> Reason<'_> {
Reason(self)
}
}
impl fmt::Display for Finding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Overcommitted {
cpu_milli,
mem_milli,
} => write!(
f,
"overcommitted: headroom cpu={cpu_milli}m memory={}B",
mem_milli / 1000
),
Self::Rejected(rejection) => write!(f, "{}: {rejection}", rejection.plugin()),
Self::NodeNotObserved { node } => {
write!(
f,
"bound to node {node:?}, which the node list does not hold"
)
}
Self::NoReplicaSetRunsTemplate => f.write_str(
"no owned ReplicaSet runs its template: the controller would create one \
and scale the others to zero",
),
Self::Invalid(v) => write!(f, "invalid: {}: {}", v.field, v.message),
Self::SchemaInvalid(v) => write!(f, "schema: {v}"),
Self::ImageInconsistent { kind, hits } => write!(f, "{kind}: {hits} hit(s)"),
Self::SubresourceLost(lost) => write!(f, "{lost}"),
}
}
}
struct Reason<'a>(&'a Finding);
impl fmt::Display for Reason<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Finding::Overcommitted { .. } => f.write_str("overcommitted"),
Finding::Rejected(rejection) => f.write_str(rejection.plugin().name()),
Finding::NodeNotObserved { .. } => f.write_str("node_not_observed"),
Finding::NoReplicaSetRunsTemplate => f.write_str("no_replicaset_runs_template"),
Finding::Invalid(v) => f.write_str(&v.field),
Finding::SchemaInvalid(v) => f.write_str(&v.path),
Finding::ImageInconsistent { kind, .. } => f.write_str(kind.reason()),
Finding::SubresourceLost(lost) => write!(f, "{}", lost.resource()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
pub subject: Subject,
pub finding: Finding,
}
impl fmt::Display for Match {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.subject, self.finding)
}
}
#[derive(Debug, Clone)]
pub struct Report {
predicate: Predicate,
source: String,
examined: BTreeMap<Gvk, usize>,
unlisted: BTreeMap<Gvk, String>,
matches: Vec<Match>,
}
impl Report {
fn new(predicate: Predicate, source: String) -> Self {
Self {
predicate,
source,
examined: BTreeMap::new(),
unlisted: BTreeMap::new(),
matches: Vec::new(),
}
}
async fn read<S: Source + ?Sized>(
&mut self,
source: &S,
gvk: &Gvk,
) -> Result<Vec<Value>, CensusError> {
let objects = source.list(gvk).await?;
*self.examined.entry(gvk.clone()).or_default() += objects.len();
Ok(objects)
}
#[must_use]
pub fn predicate(&self) -> Predicate {
self.predicate
}
#[must_use]
pub fn matches(&self) -> &[Match] {
&self.matches
}
#[must_use]
pub fn examined(&self) -> &BTreeMap<Gvk, usize> {
&self.examined
}
#[must_use]
pub fn unlisted(&self) -> &BTreeMap<Gvk, String> {
&self.unlisted
}
#[must_use]
pub fn subjects_matched(&self) -> usize {
self.matches
.iter()
.map(|m| &m.subject)
.collect::<BTreeSet<_>>()
.len()
}
#[must_use]
pub fn counts(&self) -> BTreeMap<(String, String), usize> {
let mut counts = BTreeMap::new();
for m in &self.matches {
let key = (
m.subject.group().to_string(),
m.finding.reason().to_string(),
);
*counts.entry(key).or_default() += 1;
}
counts
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"census {} ({}) over {}",
self.predicate,
self.predicate.plan_row(),
self.source
)?;
for (gvk, n) in &self.examined {
writeln!(f, "examined {gvk}: {n}")?;
}
for (gvk, url) in &self.unlisted {
writeln!(
f,
"unlisted {gvk}: discovery lists it, but GET {url} answers 405; not judged"
)?;
}
for m in &self.matches {
writeln!(f, "match {m}")?;
}
for ((group, reason), n) in self.counts() {
writeln!(f, "count {group} {reason}: {n}")?;
}
writeln!(
f,
"total: {} finding(s) over {} subject(s)",
self.matches.len(),
self.subjects_matched()
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum CensusError {
#[error("{predicate} reads the store's durable image, which only a data directory has")]
NeedsDataDir {
predicate: Predicate,
},
#[error("the apiserver serves no listable kind {gvk}")]
KindNotServed {
gvk: Gvk,
},
#[error("discovery lists {gvk} as listable, but GET {url} answers 405 Method Not Allowed")]
NotListable {
gvk: Gvk,
url: String,
},
#[error("GET {url}: {source}")]
Http {
url: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("GET {url}: HTTP {code}")]
Status {
url: String,
code: u16,
},
#[error("GET {url}: the body is not JSON: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error("GET {url}: {shape}")]
Shape {
url: String,
shape: Shape,
},
#[error(transparent)]
Kube(#[from] KubeError),
#[error("no store directory at {}: {source}", path.display())]
NoStore {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("{} is neither a file nor a directory; the census copies only those", path.display())]
UncopyableEntry {
path: std::path::PathBuf,
},
#[error("copy {} to {}: {source}", from.display(), to.display())]
Copy {
from: std::path::PathBuf,
to: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("the store at {} holds no raft state; nothing was ever written there", path.display())]
EmptyStore {
path: std::path::PathBuf,
},
#[error(transparent)]
Store(#[from] StoreError),
#[error("no OS entropy to name the private store copy: {0}")]
Entropy(getrandom::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shape {
NoItems,
NoDiscovery,
Unnamed(DiscoveryEntry),
}
impl fmt::Display for Shape {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoItems => f.write_str("the LIST body has no `items` array"),
Self::NoDiscovery => f.write_str("the discovery body has none of the arrays it names"),
Self::Unnamed(entry) => write!(
f,
"discovery lists {entry}; a census cannot list what it cannot name"
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscoveryEntry {
CoreVersion,
Group,
GroupVersion,
Resource,
}
impl fmt::Display for DiscoveryEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::CoreVersion => "an `/api` version that is not a string",
Self::Group => "a group with no `name`",
Self::GroupVersion => "a group version with no `version`",
Self::Resource => "a resource with no `name`, or a collection with no `kind`",
})
}
}
engenho_substrate::impl_error_kind! {
CensusError {
{ NeedsDataDir { .. } } => "needs_data_dir",
{ KindNotServed { .. } } => "kind_not_served",
{ NotListable { .. } } => "not_listable",
{ Http { .. } } => "http",
{ Status { .. } } => "status",
{ Decode { .. } } => "decode",
{ Shape { .. } } => "shape",
(Kube(_)) => "kube",
{ NoStore { .. } } => "no_store",
{ UncopyableEntry { .. } } => "uncopyable_entry",
{ Copy { .. } } => "copy",
{ EmptyStore { .. } } => "empty_store",
(Store(_)) => "store",
(Entropy(_)) => "entropy",
}
}
pub async fn run<S: Source + ?Sized>(
predicate: Predicate,
source: &S,
) -> Result<Report, CensusError> {
run_at(predicate, source, &engenho_types::time::now_rfc3339_utc()).await
}
async fn run_at<S: Source + ?Sized>(
predicate: Predicate,
source: &S,
now: &str,
) -> Result<Report, CensusError> {
let mut report = Report::new(predicate, source.label());
let found = match predicate {
Predicate::NodeOvercommitted => {
let nodes = report.read(source, &Builtin::Node.gvk()).await?;
let pods = report.read(source, &Builtin::Pod.gvk()).await?;
predicates::node_overcommitted(&nodes, &pods)
}
Predicate::BoundPodRejected => {
let nodes = report.read(source, &Builtin::Node.gvk()).await?;
let pods = report.read(source, &Builtin::Pod.gvk()).await?;
let leases = report.read(source, &Builtin::Lease.gvk()).await?;
predicates::bound_pod_rejected(&nodes, &pods, &leases, now)
}
Predicate::DeploymentWouldRoll => {
let deployments = report.read(source, &Builtin::Deployment.gvk()).await?;
let replicasets = report.read(source, &Builtin::ReplicaSet.gvk()).await?;
predicates::deployment_would_roll(&deployments, &replicasets)
}
Predicate::StoredWouldReject => {
let crds = source
.list(&Builtin::CustomResourceDefinition.gvk())
.await?;
let schemas = predicates::crd_schemas(&crds);
let mut found = Vec::new();
for gvk in source.kinds().await? {
match report.read(source, &gvk).await {
Ok(objects) => {
found.extend(predicates::would_reject(&gvk, &objects, schemas.get(&gvk)));
}
Err(CensusError::NotListable { gvk, url }) => {
report.unlisted.insert(gvk, url);
}
Err(e) => return Err(e),
}
}
found
}
Predicate::StoreImageInconsistent => {
let Some(tripwire) = source.image().await else {
return Err(CensusError::NeedsDataDir { predicate });
};
predicates::image_inconsistent(&tripwire)
}
Predicate::RbacBareParentOnly => {
let listed = rbac::Listed {
cluster_role_bindings: report
.read(source, &Builtin::ClusterRoleBinding.gvk())
.await?,
role_bindings: report.read(source, &Builtin::RoleBinding.gvk()).await?,
cluster_roles: report.read(source, &Builtin::ClusterRole.gvk()).await?,
roles: report.read(source, &Builtin::Role.gvk()).await?,
crds: source
.list(&Builtin::CustomResourceDefinition.gvk())
.await?,
};
rbac::bare_parent_only(listed).await
}
};
report.matches = found;
Ok(report)
}
fn text_at<'a>(v: &'a Value, pointer: &str) -> Option<&'a str> {
v.pointer(pointer).and_then(Value::as_str)
}
#[cfg(test)]
mod tests;