use serde::{Deserialize, Serialize};
use super::capability::{AttrValue, Capability, CapabilityId, CapabilityState};
use super::memory::{MEM_SYSTEM, MEM_UNIFIED};
pub const NODE_FACTS_VERSION: u32 = 1;
pub const MAX_FACTS_TTL_SECS: u64 = 86_400;
pub const MIN_FACTS_TTL_SECS: u64 = 10;
pub const MAX_FACTS_CAPABILITIES: usize = 512;
pub const MAX_PROBE_NOTES: usize = 64;
pub const MAX_NOTE_LEN: usize = 512;
pub const MAX_NODE_ID_LEN: usize = 128;
pub const MAX_DELTA_CHANGES: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FactsError {
#[error("invalid node facts: {0}")]
Invalid(String),
#[error("node facts expired at {expired_at} (now {now})")]
Expired {
expired_at: u64,
now: u64,
},
#[error("node facts issued in the future ({issued_at} > {now} + skew)")]
FromFuture {
issued_at: u64,
now: u64,
},
#[error("delta rejected: {0}")]
Delta(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustTier {
Discovered,
Paired,
Pinned,
}
impl TrustTier {
pub fn capability_id(self) -> &'static str {
match self {
TrustTier::Discovered => "trust.tier.discovered",
TrustTier::Paired => "trust.tier.paired",
TrustTier::Pinned => "trust.tier.pinned",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProbeNote {
pub probe: String,
pub note: String,
}
impl ProbeNote {
pub fn new(probe: impl Into<String>, note: impl Into<String>) -> Self {
Self {
probe: clip(probe.into()),
note: clip(note.into()),
}
}
}
fn clip(mut s: String) -> String {
if s.len() > MAX_NOTE_LEN {
let mut end = MAX_NOTE_LEN;
while !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
}
s
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeLoad {
pub busy: usize,
pub total: usize,
pub mem_free: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeFacts {
pub version: u32,
pub node_id: String,
pub issued_at: u64,
pub ttl_secs: u64,
pub seq: u64,
pub capabilities: Vec<Capability>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<ProbeNote>,
}
impl NodeFacts {
pub fn new(node_id: impl Into<String>, issued_at: u64, ttl_secs: u64, seq: u64) -> Self {
Self {
version: NODE_FACTS_VERSION,
node_id: node_id.into(),
issued_at,
ttl_secs,
seq,
capabilities: Vec::new(),
notes: Vec::new(),
}
}
pub fn node_id(&self) -> &str {
&self.node_id
}
pub fn capabilities(&self) -> &[Capability] {
&self.capabilities
}
pub fn find<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a Capability> + 'a {
self.capabilities
.iter()
.filter(move |c| c.id.as_str() == id)
}
pub fn expires_at(&self) -> u64 {
self.issued_at.saturating_add(self.ttl_secs)
}
pub fn is_fresh(&self, now: u64) -> bool {
now < self.expires_at()
}
pub fn load(&self) -> NodeLoad {
let busy = self
.capabilities
.iter()
.filter(|c| matches!(c.state, CapabilityState::Busy | CapabilityState::Reserved))
.count();
let mem = self
.find(MEM_UNIFIED)
.next()
.or_else(|| self.find(MEM_SYSTEM).next());
NodeLoad {
busy,
total: self.capabilities.len(),
mem_free: mem.and_then(|c| c.attrs.get("free")).and_then(|v| match v {
AttrValue::Int(i) if *i >= 0 => Some(*i as u64),
_ => None,
}),
}
}
pub fn validate(&self) -> Result<(), FactsError> {
let bad = |s: &str| Err(FactsError::Invalid(s.to_string()));
if self.version != NODE_FACTS_VERSION {
return bad("unsupported version");
}
if self.node_id.is_empty() || self.node_id.len() > MAX_NODE_ID_LEN {
return bad("node_id must be 1..=128 bytes");
}
if !self
.node_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
{
return bad("node_id has characters outside [A-Za-z0-9-_.:]");
}
if !(MIN_FACTS_TTL_SECS..=MAX_FACTS_TTL_SECS).contains(&self.ttl_secs) {
return bad("ttl_secs out of range");
}
if self.capabilities.len() > MAX_FACTS_CAPABILITIES {
return bad("too many capabilities");
}
if self.notes.len() > MAX_PROBE_NOTES {
return bad("too many probe notes");
}
if self
.notes
.iter()
.any(|n| n.probe.len() > MAX_NOTE_LEN || n.note.len() > MAX_NOTE_LEN)
{
return bad("probe note too long");
}
for cap in &self.capabilities {
if cap.id.family() == "trust" {
return bad("trust.* is assigned by the receiver, not self-asserted");
}
cap.validate()
.map_err(|e| FactsError::Invalid(e.to_string()))?;
}
Ok(())
}
pub fn check_window(&self, now: u64, max_skew_secs: u64) -> Result<(), FactsError> {
self.validate()?;
if self.issued_at > now.saturating_add(max_skew_secs) {
return Err(FactsError::FromFuture {
issued_at: self.issued_at,
now,
});
}
if !self.is_fresh(now) {
return Err(FactsError::Expired {
expired_at: self.expires_at(),
now,
});
}
Ok(())
}
pub fn apply_delta(&mut self, d: &FactsDelta) -> Result<(), FactsError> {
let bad = |s: String| Err(FactsError::Delta(s));
if d.node_id != self.node_id {
return bad(format!(
"delta for {} applied to {}",
d.node_id, self.node_id
));
}
if d.base_seq != self.seq {
return bad(format!(
"delta base_seq {} != facts seq {}",
d.base_seq, self.seq
));
}
if d.changes.len() > MAX_DELTA_CHANGES {
return bad("too many changes".into());
}
for ch in &d.changes {
match self.capabilities.get(ch.index as usize) {
Some(cap) if cap.id == ch.id => {}
_ => return bad(format!("no capability {} at index {}", ch.id, ch.index)),
}
}
for ch in &d.changes {
self.capabilities[ch.index as usize].state = ch.state;
}
if let Some(free) = d.mem_free {
let free = AttrValue::Int(i64::try_from(free).unwrap_or(i64::MAX));
for cap in self
.capabilities
.iter_mut()
.filter(|c| matches!(c.id.as_str(), MEM_UNIFIED | MEM_SYSTEM))
{
cap.attrs.insert("free".into(), free.clone());
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StateChange {
pub index: u32,
pub id: CapabilityId,
pub state: CapabilityState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FactsDelta {
pub node_id: String,
pub base_seq: u64,
pub seq: u64,
pub issued_at: u64,
#[serde(default)]
pub changes: Vec<StateChange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_free: Option<u64>,
}