use std::sync::Mutex;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BootState {
Pending,
Applying,
Applied,
AlreadyApplied,
Superseded,
Failed,
}
impl BootState {
pub fn is_serving(self) -> bool {
matches!(
self,
Self::Applied | Self::AlreadyApplied | Self::Superseded
)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BootPackageStatus {
pub file: String,
pub name: String,
pub version: String,
pub content_hash: String,
pub state: BootState,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub struct BootPackages {
entries: Mutex<Vec<BootPackageStatus>>,
failure: Mutex<Option<String>>,
failed: tokio::sync::watch::Sender<bool>,
}
impl BootPackages {
pub fn new(files: &[String]) -> Self {
let entries = files
.iter()
.map(|file| BootPackageStatus {
file: file.clone(),
name: String::new(),
version: String::new(),
content_hash: String::new(),
state: BootState::Pending,
error: None,
})
.collect();
Self {
entries: Mutex::new(entries),
failure: Mutex::new(None),
failed: tokio::sync::watch::channel(false).0,
}
}
pub fn is_empty(&self) -> bool {
self.lock_entries().is_empty()
}
pub fn component(&self) -> Option<&'static str> {
let entries = self.lock_entries();
if entries.is_empty() {
return None;
}
Some(if self.failure().is_some() {
"failed"
} else if entries.iter().all(|e| e.state.is_serving()) {
"ok"
} else {
"applying"
})
}
pub fn snapshot(&self) -> Vec<BootPackageStatus> {
self.lock_entries().clone()
}
pub fn update(&self, index: usize, edit: impl FnOnce(&mut BootPackageStatus)) {
if let Some(entry) = self.lock_entries().get_mut(index) {
edit(entry);
}
}
pub fn fail(&self, index: usize, error: String) {
self.update(index, |entry| {
entry.state = BootState::Failed;
entry.error = Some(error.clone());
});
let mut failure = self.failure.lock().unwrap_or_else(|e| e.into_inner());
if failure.is_none() {
*failure = Some(error);
}
drop(failure);
self.failed.send_replace(true);
}
pub fn failure(&self) -> Option<String> {
self.failure
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub async fn failed(&self) {
let mut failed = self.failed.subscribe();
let _ = failed.wait_for(|failed| *failed).await;
}
fn lock_entries(&self) -> std::sync::MutexGuard<'_, Vec<BootPackageStatus>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_configured_reports_no_component() {
let status = BootPackages::new(&[]);
assert!(status.is_empty());
assert_eq!(status.component(), None);
}
#[tokio::test]
async fn the_component_follows_the_entries_and_a_failure_wakes_the_waiter() {
let status = BootPackages::new(&["a.json".to_string(), "b.json".to_string()]);
assert_eq!(status.component(), Some("applying"));
status.update(0, |e| e.state = BootState::Applied);
assert_eq!(status.component(), Some("applying"));
status.update(1, |e| e.state = BootState::Superseded);
assert_eq!(status.component(), Some("ok"));
let waiter = tokio::time::timeout(std::time::Duration::from_millis(50), status.failed());
assert!(waiter.await.is_err(), "no failure, no wake-up");
status.fail(1, "b.json: refused".to_string());
status.fail(0, "a.json: later".to_string());
tokio::time::timeout(std::time::Duration::from_secs(1), status.failed())
.await
.expect("a recorded failure resolves at once");
assert_eq!(status.component(), Some("failed"));
assert_eq!(status.failure().as_deref(), Some("b.json: refused"));
assert_eq!(status.snapshot()[1].state, BootState::Failed);
}
}