use std::collections::HashMap;
use serde::{Deserialize, Serialize};
const SESSION_GONE: [u16; 2] = [401, 440];
const STAND_DOWN: [u16; 3] = [502, 503, 504];
pub const RETOAST: u64 = 10 * 60;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
SessionExpired,
StandDown,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Signal {
pub kind: Kind,
pub app: String,
pub server: String,
pub status: Option<u16>,
pub since: u64,
}
impl Signal {
fn key(&self) -> Key {
key(self.kind, &self.app, &self.server)
}
}
type Key = (Kind, String, String);
fn key(kind: Kind, app: &str, server: &str) -> Key {
match kind {
Kind::StandDown => (kind, String::new(), server.to_string()),
Kind::SessionExpired => (kind, app.to_string(), server.to_string()),
}
}
#[derive(Clone, Copy, Debug)]
pub enum Seen {
Answered {
status: u16,
sent_cookies: bool,
got_cookies: bool,
},
Unreachable,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Change {
pub raised: Vec<Signal>,
pub toast: Vec<Signal>,
pub cleared: Vec<Signal>,
}
impl Change {
pub fn is_empty(&self) -> bool {
self.raised.is_empty() && self.cleared.is_empty()
}
}
#[derive(Default)]
pub struct Board {
active: Vec<Signal>,
toasted: HashMap<Key, u64>,
}
impl Board {
pub fn active(&self) -> &[Signal] {
&self.active
}
pub fn observe(&mut self, app: &str, server: &str, seen: Seen, now: u64) -> Change {
let mut change = Change::default();
let down = match seen {
Seen::Unreachable => Some(None),
Seen::Answered { status, .. } if STAND_DOWN.contains(&status) => Some(Some(status)),
Seen::Answered { .. } => None,
};
match down {
Some(status) => self.raise(Kind::StandDown, app, server, status, now, &mut change),
None => self.clear(Kind::StandDown, app, server, &mut change),
}
if let Seen::Answered {
status,
sent_cookies,
got_cookies,
} = seen
{
if got_cookies && status < 400 {
self.clear(Kind::SessionExpired, app, server, &mut change);
} else if sent_cookies && SESSION_GONE.contains(&status) {
self.raise(Kind::SessionExpired, app, server, Some(status), now, &mut change);
}
}
change
}
pub fn renewed(&mut self, app: &str, server: &str) -> Change {
let mut change = Change::default();
self.clear(Kind::SessionExpired, app, server, &mut change);
change
}
fn raise(&mut self, kind: Kind, app: &str, server: &str, status: Option<u16>, now: u64, change: &mut Change) {
let key = key(kind, app, server);
if self.active.iter().any(|signal| signal.key() == key) {
return;
}
let signal = Signal {
kind,
app: app.to_string(),
server: server.to_string(),
status,
since: now,
};
let due = self.toasted.get(&key).is_none_or(|at| now.saturating_sub(*at) >= RETOAST);
if due {
self.toasted.insert(key, now);
change.toast.push(signal.clone());
}
self.active.push(signal.clone());
change.raised.push(signal);
}
fn clear(&mut self, kind: Kind, app: &str, server: &str, change: &mut Change) {
let key = key(kind, app, server);
if let Some(index) = self.active.iter().position(|signal| signal.key() == key) {
change.cleared.push(self.active.remove(index));
}
}
}
pub fn describe(signal: &Signal) -> String {
match signal.kind {
Kind::StandDown => match signal.status {
Some(status) => format!("'{}' answers {status} - the stand is down, not the app", signal.server),
None => format!("'{}' does not answer - the stand is down or out of reach", signal.server),
},
Kind::SessionExpired => format!(
"the '{}' session of '{}' expired - log in again, or hand it a cookie with `turnout cookie set {}`",
signal.server, signal.app, signal.app
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn answered(status: u16) -> Seen {
Seen::Answered {
status,
sent_cookies: true,
got_cookies: false,
}
}
#[test]
fn a_stand_that_is_down_raises_one_signal_until_it_answers() {
let mut board = Board::default();
let change = board.observe("web", "prod", answered(502), 100);
assert_eq!(change.raised.len(), 1);
assert_eq!(change.toast.len(), 1);
assert_eq!(change.raised[0].status, Some(502));
for _ in 0..5 {
assert!(board.observe("web", "prod", answered(503), 101).is_empty(), "a burst is still one outage");
}
assert!(board.observe("admin", "prod", Seen::Unreachable, 102).is_empty());
assert_eq!(board.active().len(), 1);
let change = board.observe("admin", "prod", answered(404), 103);
assert_eq!(change.cleared.len(), 1, "any answer below 500 means the stand is back");
assert!(board.active().is_empty());
}
#[test]
fn a_flapping_stand_does_not_toast_again_too_soon() {
let mut board = Board::default();
assert_eq!(board.observe("web", "prod", answered(502), 1_000).toast.len(), 1);
board.observe("web", "prod", answered(200), 1_010);
let again = board.observe("web", "prod", answered(502), 1_020);
assert_eq!(again.raised.len(), 1, "status shows it");
assert!(again.toast.is_empty(), "the desktop does not hear it twice in ten minutes");
board.observe("web", "prod", answered(200), 1_030);
let later = board.observe("web", "prod", answered(502), 1_000 + RETOAST);
assert_eq!(later.toast.len(), 1);
}
#[test]
fn stands_are_told_apart() {
let mut board = Board::default();
board.observe("web", "prod", answered(502), 1);
board.observe("web", "dev", Seen::Unreachable, 2);
assert_eq!(board.active().len(), 2);
assert_eq!(board.active()[1].status, None);
}
#[test]
fn a_session_expires_only_when_cookies_were_sent() {
let mut board = Board::default();
let first_visit = Seen::Answered {
status: 401,
sent_cookies: false,
got_cookies: false,
};
assert!(board.observe("web", "prod", first_visit, 1).is_empty());
let change = board.observe("web", "prod", answered(401), 2);
assert_eq!(change.raised[0].kind, Kind::SessionExpired);
assert_eq!(change.toast.len(), 1);
assert!(
board.observe("web", "prod", answered(440), 3).is_empty(),
"IIS's code for it is the same signal"
);
assert_eq!(board.observe("admin", "prod", answered(401), 4).raised.len(), 1);
}
#[test]
fn only_a_successful_answer_with_cookies_renews_a_session() {
let mut board = Board::default();
board.observe("web", "prod", answered(401), 1);
let deleting = Seen::Answered {
status: 401,
sent_cookies: true,
got_cookies: true,
};
assert!(board.observe("web", "prod", deleting, 2).is_empty());
assert_eq!(board.active().len(), 1);
assert!(
board.observe("web", "prod", answered(200), 3).is_empty(),
"a page that sets nothing proves nothing"
);
let login = Seen::Answered {
status: 302,
sent_cookies: true,
got_cookies: true,
};
assert_eq!(board.observe("web", "prod", login, 4).cleared.len(), 1);
assert!(board.active().is_empty());
}
#[test]
fn cookies_set_by_hand_renew_the_session() {
let mut board = Board::default();
board.observe("web", "prod", answered(401), 1);
assert_eq!(board.renewed("web", "prod").cleared.len(), 1);
assert!(board.renewed("web", "prod").is_empty());
}
#[test]
fn signals_read_back_as_written() {
let signal = Signal {
kind: Kind::SessionExpired,
app: "web".into(),
server: "prod".into(),
status: Some(401),
since: 9,
};
let text = serde_json::to_string(&signal).unwrap();
assert!(text.contains(r#""kind":"session_expired""#), "{text}");
assert_eq!(serde_json::from_str::<Signal>(&text).unwrap(), signal);
}
}