#![deny(clippy::wildcard_enum_match_arm)]
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use engenho_config::EngenhoConfig;
use engenho_controllers::{
Beat, Controller, ControllerError, DeclaresReads, Heartbeat, Reads, ReconcileOutcome, TickClass,
};
use engenho_store::StoreMesh;
use engenho_substrate::{Clock as _, Liveness, WallClock};
use shikumi::TieredConfig as _;
use tokio::time::Instant;
use crate::boot_config::BootConfig;
use crate::child::{
Child, ChildState, ChildTask, Children, DeathCause, Fault, Listener, Supervision, TickLoop,
};
use crate::health::Windows;
use crate::rebind::{REBIND, serve_rebinding};
use crate::runtime::{drive, listener_task};
use crate::testing::single_voter_store;
const FALLBACK: Duration = Duration::from_millis(200);
const STUCK: Duration = Duration::from_millis(300);
#[derive(Debug, Clone, Copy)]
enum TickFault {
Panic,
Hang,
Error,
}
impl TickFault {
const fn of(fault: Fault) -> Option<Self> {
match fault {
Fault::Panic => Some(Self::Panic),
Fault::Hang => Some(Self::Hang),
Fault::Error => Some(Self::Error),
Fault::BindFailure => None,
}
}
}
struct Faulty(TickFault);
#[cfg(test)]
#[async_trait::async_trait]
impl Controller for Faulty {
fn name(&self) -> &'static str {
"fault-matrix"
}
async fn tick(&self) -> Result<ReconcileOutcome, ControllerError> {
match self.0 {
TickFault::Panic => panic!("injected: the tick panicked"),
TickFault::Hang => std::future::pending().await,
TickFault::Error => Err(ControllerError::Internal(
"injected: a Transient failure".into(),
)),
}
}
}
#[cfg(test)]
impl DeclaresReads for Faulty {
fn reads(&self) -> Reads {
Reads::nothing()
}
}
async fn panicking_attempt() {
panic!("injected: the serve attempt panicked");
}
async fn returning_attempt() {}
struct World {
store: Arc<StoreMesh>,
windows: Windows,
boot: BootConfig,
_taken: std::net::TcpListener,
}
impl World {
async fn new() -> Self {
let taken = std::net::TcpListener::bind("127.0.0.1:0").expect("a port to hold");
let addr = taken
.local_addr()
.expect("the held port's address")
.to_string();
let mut config = EngenhoConfig::prescribed_default();
config.runtime.kubelet_listen_addr.clone_from(&addr);
config.runtime.etcd_listen_addr = addr;
let boot = BootConfig::read(&config).expect("the prescribed default is run");
let windows = boot
.windows(FALLBACK)
.with_fallback(FALLBACK)
.with_stuck_tick_after(STUCK);
Self {
store: single_voter_store("fault-matrix").await,
windows,
boot,
_taken: taken,
}
}
}
fn inject(child: Child, fault: Fault, world: &World) -> Option<ChildTask> {
match child {
Child::Driver(driver) => tick_loop_with(TickLoop::Driver(driver), fault, world),
Child::NodeLease => tick_loop_with(TickLoop::NodeLease, fault, world),
Child::Listener(listener) => Some(listener_with(listener, fault, world)),
}
}
fn tick_loop_with(tick_loop: TickLoop, fault: Fault, world: &World) -> Option<ChildTask> {
let tick = TickFault::of(fault)?;
Some(drive(tick_loop, Faulty(tick), &world.store, world.windows))
}
fn listener_with(listener: Listener, fault: Fault, world: &World) -> ChildTask {
let beat = Arc::new(Heartbeat::new());
match fault {
Fault::Panic => ChildTask::new(
beat.clone(),
serve_rebinding(listener, beat, panicking_attempt),
),
Fault::Hang => ChildTask::new(
beat.clone(),
serve_rebinding(listener, beat, std::future::pending::<()>),
),
Fault::Error => ChildTask::new(
beat.clone(),
serve_rebinding(listener, beat, returning_attempt),
),
Fault::BindFailure => listener_task(
listener,
&world.boot,
std::sync::Weak::new(),
&world.store,
&crate::runtime::KubeletEndpoint::default(),
),
}
}
#[derive(Debug)]
struct Seen {
state: ChildState,
beat: Beat,
liveness: Liveness,
}
impl Seen {
fn of(children: &Children, child: Child, windows: Windows) -> Option<Self> {
let state = children.get(child)?.state();
let row = children.row(child)?;
Some(Self {
state,
beat: children.get(child)?.beat().snapshot(),
liveness: row.liveness(windows, Instant::now(), WallClock.now()),
})
}
fn outcome(&self) -> Option<Supervision> {
let beat = &self.beat;
let alive = self.liveness == Liveness::Alive;
let stalled = matches!(self.liveness, Liveness::Stalled { .. });
let one_tick_in_flight = beat.in_flight() && beat.ticks_started == 1;
let ticked_again = beat.ticks_started >= 2;
if self.state == ChildState::Dead(DeathCause::Panicked)
&& self.liveness == Liveness::Dead
&& beat.ticks_started == 1
{
return Some(Supervision::Dead);
}
if self.state != ChildState::Running {
return None;
}
if one_tick_in_flight && stalled {
return Some(Supervision::Stalled);
}
if one_tick_in_flight && alive {
return Some(Supervision::Unobserved);
}
if ticked_again && alive && beat.last_class == Some(TickClass::Panicked) {
return Some(Supervision::Contained);
}
if ticked_again && alive && beat.last_class == Some(TickClass::Transient) {
return Some(Supervision::Retried);
}
if ticked_again
&& stalled
&& !beat.in_flight()
&& matches!(
beat.last_class,
Some(TickClass::Halted | TickClass::Panicked)
)
{
return Some(Supervision::Rebinds);
}
None
}
fn panics_counted(&self, fault: Fault) -> bool {
match fault {
Fault::Panic => self.beat.panics == self.beat.ticks_finished.max(1),
Fault::Hang | Fault::Error | Fault::BindFailure => self.beat.panics == 0,
}
}
}
#[derive(Debug)]
struct Cell {
child: Child,
fault: Fault,
declared: Supervision,
seen: Option<Seen>,
observed: Option<Supervision>,
}
impl Cell {
fn read(children: &Children, child: Child, fault: Fault, windows: Windows) -> Self {
let seen = Seen::of(children, child, windows);
Self {
child,
fault,
declared: child.supervision(fault),
observed: seen.as_ref().and_then(Seen::outcome),
seen,
}
}
fn holds(&self) -> bool {
match &self.seen {
None => self.declared == Supervision::Inapplicable,
Some(seen) => self.observed == Some(self.declared) && seen.panics_counted(self.fault),
}
}
}
async fn drain_deaths(children: &mut Children) {
while tokio::time::timeout(Duration::from_millis(50), children.next_dead())
.await
.is_ok()
{}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_child_meets_every_fault_as_the_catalog_declares() {
let world = World::new().await;
let settle = REBIND.base() * 5 / 2;
let mut sets: Vec<(Fault, Children)> = Fault::ALL
.iter()
.map(|&fault| {
let children =
Children::spawn_catalog(&world.boot, |child, _| inject(child, fault, &world));
(fault, children)
})
.collect();
tokio::time::sleep(settle).await;
let mut cells = Vec::new();
for (fault, children) in &mut sets {
drain_deaths(children).await;
cells.extend(Child::all().map(|child| Cell::read(children, child, *fault, world.windows)));
}
for (_, children) in &mut sets {
children.stop().await;
}
let read: BTreeSet<(Child, Fault)> = cells.iter().map(|c| (c.child, c.fault)).collect();
let product: BTreeSet<(Child, Fault)> = Child::all()
.flat_map(|child| Fault::ALL.iter().map(move |fault| (child, *fault)))
.collect();
assert_eq!(
(read.len(), &read),
(cells.len(), &product),
"the cells read are not every (child, fault) pair, each once"
);
let declared: BTreeSet<Supervision> = cells.iter().map(|c| c.declared).collect();
let every: BTreeSet<Supervision> = Supervision::ALL.iter().copied().collect();
assert_eq!(
declared, every,
"an outcome the catalog can declare is declared by no cell, so nothing exercises it"
);
let wrong: Vec<&Cell> = cells.iter().filter(|c| !c.holds()).collect();
assert!(
wrong.is_empty(),
"{} of {} cells did not do what the catalog declares: {wrong:#?}",
wrong.len(),
cells.len()
);
}
#[test]
fn the_declaration_follows_the_catalog_rows() {
use crate::child::Driver;
assert_eq!(
Child::Driver(Driver::Kubelet).supervision(Fault::Panic),
Supervision::Dead
);
assert_eq!(
Child::Driver(Driver::Gc).supervision(Fault::Panic),
Supervision::Contained
);
for child in Child::all() {
let binds = matches!(child, Child::Listener(_));
assert_eq!(
child.supervision(Fault::BindFailure) == Supervision::Inapplicable,
!binds,
"{child}: a bind failure applies exactly to the children that bind"
);
if let Some(tick_loop) = child.tick_loop() {
let fatal = tick_loop.tick_state() == crate::TickState::Stateful;
assert_eq!(
child.supervision(Fault::Panic),
if fatal {
Supervision::Dead
} else {
Supervision::Contained
},
"{child}: a panic is decided by the tick loop's own row"
);
}
}
}