1use std::collections::BTreeMap;
11use std::sync::{Arc, Mutex, RwLock};
12use std::time::Duration;
13
14use clawft_types::placement::TrustTier as FactsTier;
15use clawft_types::placement::engine::{Liveness, ScoringWeights};
16use ed25519_dalek::SigningKey;
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use crate::chain::ChainManager;
21use crate::cluster::ClusterMembership;
22use crate::gate::GateBackend;
23use crate::mesh_artifact::ArtifactExchange;
24use crate::mesh_service_adv::{ClusterServiceRegistry, ServiceAdvertisement};
25use crate::node_facts::NodeFactsCache;
26use crate::node_facts_advert::SignedNodeFacts;
27use crate::node_registry::node_id_from_pubkey;
28use crate::workload_kind::KindRegistry;
29use crate::workload_pkg::TrustAnchors;
30use crate::workload_runtime::WorkloadHost;
31
32use super::facts::{LiveNodeFacts, placement_view};
33use super::msg::{ANY_TARGET, CtlOutcome, CtlRequest, Refusal, method, verify_response};
34use super::session::CtlConnection;
35use super::transport::CtlConnector;
36
37pub const PLANE_CHAIN_SOURCE: &str = "workload.placement";
39
40#[derive(Debug, Clone)]
42pub struct PlaneConfig {
43 pub call_timeout: Duration,
45 pub place_timeout: Duration,
47 pub request_ttl_ms: u64,
49 pub weights: ScoringWeights,
51 pub dead_after: Duration,
55 pub reschedule_timeout: Duration,
59 pub retry_backoff: Duration,
62}
63
64impl Default for PlaneConfig {
65 fn default() -> Self {
66 Self {
67 call_timeout: Duration::from_secs(20),
68 place_timeout: Duration::from_secs(280),
69 request_ttl_ms: 290_000,
70 weights: ScoringWeights::default(),
71 dead_after: Duration::from_secs(30),
72 reschedule_timeout: Duration::from_secs(60),
73 retry_backoff: Duration::from_secs(10),
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, thiserror::Error)]
80pub enum CallFailure {
81 #[error("{0}")]
83 Refused(Refusal),
84 #[error("unreachable: {0}")]
87 Unreachable(String),
88 #[error("indeterminate: {0}")]
92 Indeterminate(String),
93}
94
95#[derive(Debug, Clone, PartialEq, thiserror::Error)]
97pub enum PlaneError {
98 #[error("invalid: {0}")]
100 Invalid(String),
101 #[error("package: {0}")]
103 Package(String),
104 #[error("unknown: {0}")]
106 Unknown(String),
107 #[error(transparent)]
109 Call(#[from] CallFailure),
110 #[error("governance: {0}")]
112 Governance(String),
113 #[error("placement: {0}")]
115 Placement(String),
116 #[error(transparent)]
118 UnknownKind(#[from] crate::workload_kind::UnknownKind),
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TargetInfo {
124 pub node_id: String,
126 pub addr: String,
128 pub tier: FactsTier,
130 pub public_key: String,
132 pub reachable: bool,
134 #[serde(default)]
137 pub learned_ms: u64,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct PlacementRecord {
143 pub instance_id: String,
145 pub node_id: String,
147 pub kind: String,
149 pub workload: String,
151 pub variant: String,
153 pub decision_id: String,
155 pub manifest_hash: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub project_id: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub package_id: Option<String>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub signer_keys: Vec<String>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub artifact_hashes: Vec<String>,
173}
174
175impl PlacementRecord {
176 pub(super) fn revocation_refs(&self) -> (Option<String>, Vec<String>, Vec<String>) {
179 let mut hashes = self.artifact_hashes.clone();
180 if self.package_id.is_none() && self.signer_keys.is_empty() && hashes.is_empty() {
181 let h = self.manifest_hash.to_ascii_lowercase();
182 if h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit()) {
183 hashes.push(h);
184 }
185 }
186 (self.package_id.clone(), self.signer_keys.clone(), hashes)
187 }
188}
189
190pub(super) struct Call<'a> {
192 pub addr: &'a str,
193 pub target: &'a str,
194 pub expect: Option<[u8; 32]>,
195 pub method: &'a str,
196 pub decision_id: Option<String>,
197 pub body: Value,
198 pub serve: bool,
200 pub timeout: Option<Duration>,
203}
204
205pub struct PlacementControlPlane {
207 pub(super) node_id: String,
208 pub(super) key: SigningKey,
209 pub(super) gate: Arc<dyn GateBackend>,
210 pub(super) chain: Arc<ChainManager>,
211 pub(super) exchange: Arc<ArtifactExchange>,
212 pub(super) anchors: TrustAnchors,
213 pub(super) kinds: KindRegistry,
215 pub(super) connector: Arc<dyn CtlConnector>,
216 pub(super) facts: NodeFactsCache,
217 pub(super) targets: RwLock<BTreeMap<String, TargetInfo>>,
218 pub(super) services: Mutex<ClusterServiceRegistry>,
219 pub(super) membership: Option<Arc<ClusterMembership>>,
220 pub(super) placements: Mutex<BTreeMap<String, PlacementRecord>>,
221 pub(super) unsettled: Mutex<BTreeMap<String, PlacementRecord>>,
224 pub(super) seeds: RwLock<BTreeMap<String, (Arc<WorkloadHost>, FactsTier)>>,
227 pub(super) seed_handles: Mutex<BTreeMap<String, super::plane_seed::SeedEntry>>,
229 pub(super) cfg: PlaneConfig,
230 pub(super) lives: Mutex<BTreeMap<String, super::plane_lifecycle::ControllerLife>>,
233 pub(super) down_since: Mutex<BTreeMap<String, u64>>,
235 pub(super) state_file: Option<std::path::PathBuf>,
237 pub(super) state_lock: Mutex<()>,
238}
239
240pub(super) fn now_ms() -> u64 {
241 chrono::Utc::now().timestamp_millis().max(0) as u64
242}
243
244impl PlacementControlPlane {
245 pub fn with_kind_registry(mut self, kinds: KindRegistry) -> Self {
247 self.kinds = kinds;
248 self
249 }
250
251 pub fn new(
253 key: SigningKey,
254 gate: Arc<dyn GateBackend>,
255 chain: Arc<ChainManager>,
256 exchange: Arc<ArtifactExchange>,
257 anchors: TrustAnchors,
258 connector: Arc<dyn CtlConnector>,
259 ) -> Self {
260 Self {
261 node_id: node_id_from_pubkey(&key.verifying_key().to_bytes()),
262 key,
263 gate,
264 chain,
265 exchange,
266 anchors,
267 kinds: KindRegistry::builtin(),
268 connector,
269 facts: NodeFactsCache::new(),
270 targets: RwLock::new(BTreeMap::new()),
271 services: Mutex::new(ClusterServiceRegistry::new()),
272 membership: None,
273 placements: Mutex::new(BTreeMap::new()),
274 unsettled: Mutex::new(BTreeMap::new()),
275 seeds: RwLock::new(BTreeMap::new()),
276 seed_handles: Mutex::new(BTreeMap::new()),
277 cfg: PlaneConfig::default(),
278 lives: Mutex::new(BTreeMap::new()),
279 down_since: Mutex::new(BTreeMap::new()),
280 state_file: None,
281 state_lock: Mutex::new(()),
282 }
283 }
284
285 pub fn with_membership(mut self, m: Arc<ClusterMembership>) -> Self {
287 self.membership = Some(m);
288 self
289 }
290
291 pub fn with_config(mut self, cfg: PlaneConfig) -> Self {
293 self.cfg = cfg;
294 self
295 }
296
297 pub fn node_id(&self) -> &str {
299 &self.node_id
300 }
301
302 pub fn targets(&self) -> Vec<TargetInfo> {
304 self.targets
305 .read()
306 .map(|t| t.values().cloned().collect())
307 .unwrap_or_default()
308 }
309
310 pub fn hosts(&self) -> Vec<ServiceAdvertisement> {
312 self.services
313 .lock()
314 .map(|s| {
315 s.resolve(super::msg::WORKLOAD_HOST_SERVICE)
316 .into_iter()
317 .cloned()
318 .collect()
319 })
320 .unwrap_or_default()
321 }
322
323 pub fn placements(&self) -> Vec<PlacementRecord> {
325 self.placements
326 .lock()
327 .map(|p| p.values().cloned().collect())
328 .unwrap_or_default()
329 }
330
331 fn advertised_routes(&self) -> BTreeMap<String, Vec<String>> {
333 self.hosts()
334 .into_iter()
335 .map(|a| {
336 let routes = a
337 .metadata
338 .get("routes")
339 .map(|r| {
340 r.split(',')
341 .filter(|x| !x.is_empty())
342 .map(str::to_owned)
343 .collect()
344 })
345 .unwrap_or_default();
346 (a.node_id, routes)
347 })
348 .collect()
349 }
350
351 pub fn view(&self) -> Vec<LiveNodeFacts> {
355 let targets = self.targets.read().map(|t| t.clone()).unwrap_or_default();
356 let contact = |id: &str| {
357 targets.get(id).map(|t| {
358 if t.reachable {
359 Liveness::Alive
360 } else {
361 Liveness::Suspect
362 }
363 })
364 };
365 let routes = self.advertised_routes();
366 let mut view = placement_view(
367 &self.facts,
368 now_ms() / 1000,
369 None,
370 self.membership.as_deref(),
371 &contact,
372 );
373 for v in &mut view {
374 let r = routes.get(v.cached.node_id()).map(Vec::as_slice);
375 v.restrict_to_routes(r.unwrap_or(&[]));
376 }
377 view
378 }
379
380 pub(super) async fn round_trip(&self, c: Call<'_>) -> Result<(Value, [u8; 32]), CallFailure> {
383 let Call {
384 addr,
385 target,
386 expect,
387 method: m,
388 decision_id,
389 body,
390 serve,
391 timeout: timeout_override,
392 } = c;
393 let timeout = timeout_override.unwrap_or(if m == method::PLACE || m == method::LOAD {
394 self.cfg.place_timeout
395 } else {
396 self.cfg.call_timeout
397 });
398 let req = CtlRequest::new(
399 &self.key,
400 m,
401 target,
402 now_ms(),
403 self.cfg.request_ttl_ms,
404 decision_id,
405 body,
406 );
407 let signed = req.sign(&self.key).map_err(CallFailure::Refused)?;
408 let stream = self
409 .connector
410 .connect(addr)
411 .await
412 .map_err(|e| CallFailure::Unreachable(e.to_string()))?;
413 let mut conn = CtlConnection::new(stream, self.node_id.clone());
414 let exchange = serve.then_some(self.exchange.as_ref());
415 let r = conn.call(target, m, &signed, exchange, timeout).await;
416 conn.close().await;
417 let lost = |why: String| {
419 if method::mutates(m) {
420 CallFailure::Indeterminate(why)
421 } else {
422 CallFailure::Unreachable(why)
423 }
424 };
425 let resp_signed = r.map_err(|e| lost(e.to_string()))?;
426 let (resp, pk) = verify_response(&resp_signed, &req, expect.as_ref())
427 .map_err(|e| lost(format!("bad response: {e}")))?;
428 match resp.outcome {
429 CtlOutcome::Ok { result } => Ok((result, pk)),
430 CtlOutcome::Refused { refusal } => Err(CallFailure::Refused(refusal)),
431 }
432 }
433
434 pub async fn add_target(&self, addr: &str, tier: FactsTier) -> Result<String, PlaneError> {
439 self.add_target_expecting(addr, tier, None).await
440 }
441
442 pub async fn add_target_expecting(
447 &self,
448 addr: &str,
449 tier: FactsTier,
450 expect: Option<[u8; 32]>,
451 ) -> Result<String, PlaneError> {
452 let (result, pk) = self
453 .round_trip(Call {
454 addr,
455 target: ANY_TARGET,
456 expect,
457 method: method::DESCRIBE,
458 decision_id: None,
459 body: json!({}),
460 serve: false,
461 timeout: None,
462 })
463 .await?;
464 let node_id = node_id_from_pubkey(&pk);
465 let facts: SignedNodeFacts = serde_json::from_value(result["facts"].clone())
466 .map_err(|e| PlaneError::Invalid(format!("{node_id} sent no signed facts: {e}")))?;
467 if facts.public_key != pk.to_vec() {
468 return Err(PlaneError::Invalid(format!(
469 "{node_id}: facts signed by another key"
470 )));
471 }
472 let now = now_ms() / 1000;
473 match self.facts.insert(facts, tier, now) {
474 Ok(_) | Err(crate::node_facts::CacheError::Stale { .. }) => {}
475 Err(e) => return Err(PlaneError::Invalid(format!("{node_id} facts refused: {e}"))),
476 }
477 self.facts.set_trust_tier(&node_id, tier);
479 if let Ok(adv) =
480 serde_json::from_value::<ServiceAdvertisement>(result["advertisement"].clone())
481 && adv.node_id == node_id
482 && let Ok(mut s) = self.services.lock()
483 {
484 s.merge(adv);
485 }
486 if let Ok(mut t) = self.targets.write() {
487 let learned_ms = t.get(&node_id).map_or_else(now_ms, |e| e.learned_ms);
488 t.insert(
489 node_id.clone(),
490 TargetInfo {
491 node_id: node_id.clone(),
492 addr: addr.to_string(),
493 tier,
494 public_key: crate::workload_pkg::codec::hex_encode(&pk),
495 reachable: true,
496 learned_ms,
497 },
498 );
499 }
500 self.persist();
501 Ok(node_id)
502 }
503
504 pub async fn refresh(&self) {
511 for t in self.targets() {
512 let pk = crate::workload_pkg::codec::hex_decode_exact::<32>(&t.public_key);
513 let same = pk.is_some()
514 && matches!(self.add_target_expecting(&t.addr, t.tier, pk).await,
515 Ok(id) if id == t.node_id);
516 if !same
517 && let Ok(mut map) = self.targets.write()
518 && let Some(e) = map.get_mut(&t.node_id)
519 {
520 e.reachable = false;
521 }
522 }
523 }
524
525 pub(super) fn target(&self, node_id: &str) -> Result<(TargetInfo, [u8; 32]), PlaneError> {
526 let t = self
527 .targets
528 .read()
529 .ok()
530 .and_then(|m| m.get(node_id).cloned())
531 .ok_or_else(|| PlaneError::Unknown(format!("no workload-host known for {node_id}")))?;
532 let pk = crate::workload_pkg::codec::hex_decode_exact::<32>(&t.public_key)
533 .ok_or_else(|| PlaneError::Invalid("bad stored key".into()))?;
534 Ok((t, pk))
535 }
536
537 pub async fn call(
539 &self,
540 node_id: &str,
541 m: &str,
542 decision_id: Option<String>,
543 body: Value,
544 ) -> Result<Value, PlaneError> {
545 let (t, pk) = self.target(node_id)?;
546 Ok(self
547 .round_trip(Call {
548 addr: &t.addr,
549 target: node_id,
550 expect: Some(pk),
551 method: m,
552 decision_id,
553 body,
554 serve: false,
555 timeout: None,
556 })
557 .await?
558 .0)
559 }
560}