Skip to main content

clawft_kernel/workload_ctl/
facts.rs

1//! Card 03's verified, TTL-cached node facts as card 04's
2//! [`PlacementFacts`] (ADR-099 sections 2-3).
3//!
4//! [`CachedNodeFacts`] carries the signed facts, the receiver-assigned
5//! trust tier and live state (deltas). Liveness is not part of the facts:
6//! it comes from `ClusterMembership` peer state (heartbeats) or, for a node
7//! the control plane talks to directly, from the last signed round trip.
8//! [`LiveNodeFacts`] pairs the two, so `place()` consumes exactly what the
9//! cache verified.
10
11use clawft_types::placement::engine::{self, Liveness, PlacementFacts};
12use clawft_types::placement::{Capability, TrustTier as FactsTier};
13
14use crate::cluster::{ClusterMembership, NodeState};
15use crate::node_facts::{CachedNodeFacts, NodeFactsCache};
16use crate::workload_governance::NodeTrustTier;
17
18/// Cached facts plus liveness: what the placement engine reads.
19#[derive(Debug, Clone)]
20pub struct LiveNodeFacts {
21    /// Verified facts (base plus deltas) with the receiver-assigned tier.
22    pub cached: CachedNodeFacts,
23    /// Liveness from membership or the last direct contact.
24    pub liveness: Liveness,
25    /// The capabilities the engine sees: the verified ones, less any
26    /// runtime the node's `workload-host` has no adapter route for.
27    caps: Vec<Capability>,
28}
29
30/// The adapter route a `runtime.*` capability needs on the target
31/// (`None` for capabilities that are not runtimes).
32fn route_for(cap: &Capability) -> Option<&'static [&'static str]> {
33    let id = cap.id.as_str();
34    if id == "runtime.native" || id.starts_with("runtime.native.") {
35        Some(&["native"])
36    } else if id == "runtime.container" || id.starts_with("runtime.container.") {
37        Some(&["container", "emulated"])
38    } else {
39        None
40    }
41}
42
43impl LiveNodeFacts {
44    /// Facts as verified, with `liveness`.
45    pub fn new(cached: CachedNodeFacts, liveness: Liveness) -> Self {
46        let caps = cached.capabilities().to_vec();
47        Self {
48            cached,
49            liveness,
50            caps,
51        }
52    }
53
54    /// Hide every runtime capability whose adapter route is not in
55    /// `routes` (the routes the node's `workload-host` advertises). A node
56    /// that probes a container engine but serves no container adapter is
57    /// then not offered a container variant it would always refuse.
58    pub fn restrict_to_routes(&mut self, routes: &[String]) {
59        self.caps.retain(|c| match route_for(c) {
60            None => true,
61            Some(need) => need.iter().any(|r| routes.iter().any(|x| x == r)),
62        });
63    }
64}
65
66/// Receiver-assigned facts tier as the engine's tier.
67pub fn engine_tier(t: FactsTier) -> engine::TrustTier {
68    match t {
69        FactsTier::Discovered => engine::TrustTier::Discovered,
70        FactsTier::Paired => engine::TrustTier::Paired,
71        FactsTier::Pinned => engine::TrustTier::Pinned,
72    }
73}
74
75/// Receiver-assigned facts tier as the governance effect's tier.
76pub fn governance_tier(t: FactsTier) -> NodeTrustTier {
77    match t {
78        FactsTier::Discovered => NodeTrustTier::Discovered,
79        FactsTier::Paired => NodeTrustTier::Paired,
80        FactsTier::Pinned => NodeTrustTier::Pinned,
81    }
82}
83
84/// Membership peer state as liveness. Only `Active` is `Alive`.
85pub fn liveness_of(state: &NodeState) -> Liveness {
86    match state {
87        NodeState::Active => Liveness::Alive,
88        NodeState::Suspect => Liveness::Suspect,
89        NodeState::Unreachable | NodeState::Leaving | NodeState::Left => Liveness::Dead,
90        _ => Liveness::Unknown,
91    }
92}
93
94impl PlacementFacts for LiveNodeFacts {
95    fn node_id(&self) -> &str {
96        self.cached.node_id()
97    }
98
99    fn capabilities(&self) -> &[Capability] {
100        &self.caps
101    }
102
103    fn liveness(&self) -> Liveness {
104        self.liveness
105    }
106
107    fn trust_tier(&self) -> engine::TrustTier {
108        engine_tier(self.cached.trust_tier())
109    }
110
111    fn facts_expire_at_ms(&self) -> Option<u64> {
112        Some(self.cached.facts.expires_at().saturating_mul(1000))
113    }
114
115    /// Share of capabilities busy or reserved; unknown for an empty block.
116    fn load(&self) -> Option<f64> {
117        let l = self.cached.load();
118        (l.total > 0).then(|| l.busy as f64 / l.total as f64)
119    }
120}
121
122/// Every fresh cached node with its liveness. `local` is always alive;
123/// otherwise `membership` peer state wins, then `contact` (the control
124/// plane's last direct round trip), else [`Liveness::Unknown`].
125pub fn placement_view(
126    cache: &NodeFactsCache,
127    now_secs: u64,
128    local: Option<&str>,
129    membership: Option<&ClusterMembership>,
130    contact: &dyn Fn(&str) -> Option<Liveness>,
131) -> Vec<LiveNodeFacts> {
132    cache
133        .list(now_secs)
134        .into_iter()
135        .map(|cached| {
136            let id = cached.node_id().to_string();
137            let liveness = if local == Some(id.as_str()) {
138                Liveness::Alive
139            } else if let Some(p) = membership.and_then(|m| m.get_peer(&id)) {
140                liveness_of(&p.state)
141            } else {
142                contact(&id).unwrap_or(Liveness::Unknown)
143            };
144            LiveNodeFacts::new(cached, liveness)
145        })
146        .collect()
147}