1use std::path::PathBuf;
20
21use clawft_types::placement::engine::{
22 Affinity, ClusterState, Decision, Execution, GateInput, GateVerdict, InstanceRecord,
23 PlacementFacts, PlacementRequest, Tier, WorkloadRef, explain, place,
24};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28use crate::chain;
29use crate::gate::GateDecision;
30use crate::workload_pkg::codec::hex_encode;
31use crate::workload_runtime::VerifiedWorkload;
32
33use super::facts::governance_tier;
34use super::lifecycle::LifecyclePolicy;
35use super::host_service::{CtlConfig, InstanceBody, PlaceBody};
36use super::msg::method;
37use super::plane::{
38 Call, CallFailure, PLANE_CHAIN_SOURCE, PlacementControlPlane, PlacementRecord, PlaneError,
39 now_ms,
40};
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PlaceOrder {
45 pub package_dir: PathBuf,
47 pub config: CtlConfig,
49 #[serde(default)]
51 pub pin: Option<String>,
52 #[serde(default)]
54 pub prefer: Vec<String>,
55 #[serde(default)]
57 pub avoid: Vec<String>,
58 #[serde(default)]
60 pub allow_emulated: bool,
61 #[serde(default = "yes")]
63 pub start: bool,
64 #[serde(default)]
66 pub dry_run: bool,
67 #[serde(default)]
75 pub project_id: Option<String>,
76}
77
78fn yes() -> bool {
79 true
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct Attempt {
85 pub node_id: String,
87 pub variant: String,
89 pub outcome: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub code: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub reason: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub ingest: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct PlaceReport {
106 pub decision: Decision,
108 pub decision_id: String,
110 pub explain: String,
112 pub attempts: Vec<Attempt>,
114 pub placed: Option<PlacementRecord>,
116 #[serde(default)]
119 pub hardware_bound: bool,
120}
121
122impl PlacementControlPlane {
123 fn gate_ctx(&self, w: &VerifiedWorkload, node_id: &str, emulated: bool) -> Value {
124 let tier = self
125 .facts
126 .get(node_id, now_ms() / 1000)
127 .map(|c| governance_tier(c.trust_tier))
128 .unwrap_or(crate::workload_governance::NodeTrustTier::Discovered);
129 let (pid, keys, hashes) = w.revocation_refs();
133 let cost = w
134 .signed("placement")
135 .map(|p| (f64::from(p.spec.resources.cpu_pct) / 400.0).clamp(0.0, 1.0))
136 .unwrap_or(0.25);
137 json!({ "node_id": node_id, "workload": {
138 "kind": w.kind, "package_trust": "pinned_signer", "node_tier": tier,
139 "network": "egress", "secrets": false, "emulated": emulated,
141 "resource_cost": cost, "package_id": pid, "signer_keys": keys,
142 "artifact_hashes": hashes,
143 }})
144 }
145
146 fn gate_verdict(&self, w: &VerifiedWorkload, node_id: &str, emulated: bool) -> GateVerdict {
147 let ctx = self.gate_ctx(w, node_id, emulated);
148 match self.gate.check(&self.node_id, "workload.place", &ctx) {
149 GateDecision::Permit { .. } => GateVerdict::Permit,
150 GateDecision::Deny { reason, .. } => GateVerdict::Deny { reason },
151 GateDecision::Defer { reason } => GateVerdict::Deny {
152 reason: format!("deferred to a human (refused on this path): {reason}"),
153 },
154 }
155 }
156
157 fn cluster_state(&self) -> ClusterState {
158 let recs = self.placements();
159 ClusterState {
160 now_ms: now_ms(),
161 instances: recs
162 .iter()
163 .map(|r| InstanceRecord {
164 node_id: r.node_id.clone(),
165 workload: Some(WorkloadRef {
166 kind: r.kind.clone(),
167 name: r.workload.clone(),
168 }),
169 ..InstanceRecord::default()
170 })
171 .collect(),
172 ..ClusterState::default()
173 }
174 }
175
176 pub(super) fn chain_event(&self, kind: &str, payload: Value) -> String {
177 hex_encode(
178 &self
179 .chain
180 .append(PLANE_CHAIN_SOURCE, kind, Some(payload))
181 .hash,
182 )
183 }
184
185 pub async fn place(&self, order: &PlaceOrder) -> Result<PlaceReport, PlaneError> {
188 self.place_with(order, LifecyclePolicy::default()).await
189 }
190
191 pub async fn place_with(
195 &self,
196 order: &PlaceOrder,
197 policy: LifecyclePolicy,
198 ) -> Result<PlaceReport, PlaneError> {
199 let report = self.place_unregistered(order).await?;
200 if let Some(rec) = &report.placed {
201 let policy = LifecyclePolicy {
202 migratable: policy.migratable && !report.hardware_bound,
203 ..policy
204 };
205 self.register_life(rec, order, policy);
206 }
207 Ok(report)
208 }
209
210 pub(super) async fn place_unregistered(&self, order: &PlaceOrder) -> Result<PlaceReport, PlaneError> {
211 self.place_timed(order, None).await
212 }
213
214 pub(super) async fn place_timed(
216 &self,
217 order: &PlaceOrder,
218 place_timeout: Option<std::time::Duration>,
219 ) -> Result<PlaceReport, PlaneError> {
220 order.check()?;
221 let (w, spec, manifest_hash) = match self.prepare(order) {
222 Ok(p) => p,
223 Err(e @ PlaneError::UnknownKind(_)) => {
224 self.chain_event(
225 chain::EVENT_KIND_WORKLOAD_REFUSE,
226 json!({ "phase": "kind", "error": e.to_string() }),
227 );
228 return Err(e);
229 }
230 Err(e) => return Err(e),
231 };
232 let hardware_bound = super::lifecycle::needs_attached_hardware(&spec);
233 let view = self.view();
234 let verdicts = view
235 .iter()
236 .map(|n| {
237 (
238 n.node_id().to_string(),
239 self.gate_verdict(&w, n.node_id(), false),
240 )
241 })
242 .collect();
243 let mut req = PlacementRequest::new(spec);
244 req.pin = order.pin.clone();
245 req.affinity = Affinity {
246 prefer: order.prefer.clone(),
247 avoid: order.avoid.clone(),
248 };
249 req.allow_emulated = order.allow_emulated;
250 req.gate = GateInput::PerNode { verdicts };
251 req.weights = self.cfg.weights.clone();
252 let decided = place(&req, &view, &self.cluster_state());
253 let decision = match decided {
254 Ok(d) => d,
255 Err(e) => {
256 self.chain_event(
257 chain::EVENT_KIND_WORKLOAD_REFUSE,
258 json!({ "phase": "decision", "workload": w.id, "error": e.to_string(),
259 "constraints": e.constraints().iter().map(|c| c.as_str()).collect::<Vec<_>>() }),
260 );
261 return Err(PlaneError::Placement(e.to_string()));
262 }
263 };
264 let decision_id = self.chain_event(
265 chain::EVENT_KIND_WORKLOAD_PLACE,
266 json!({ "phase": "decision", "dry_run": order.dry_run, "controller": self.node_id,
267 "manifest_hash": manifest_hash, "decision": decision }),
268 );
269 let mut report = PlaceReport {
270 explain: String::new(),
271 decision,
272 decision_id,
273 attempts: Vec::new(),
274 placed: None,
275 hardware_bound,
276 };
277 if !order.dry_run && report.decision.placement.is_some() {
278 self.dispatch(place_timeout, order, &w, &manifest_hash, &mut report).await;
279 }
280 report.explain = render(&report);
281 Ok(report)
282 }
283
284 fn dispatch_order(d: &Decision) -> Vec<(String, String, Execution)> {
287 let Some(p) = &d.placement else { return vec![] };
288 let mut out = vec![(p.node_id.clone(), p.variant.clone(), p.execution)];
289 if d.pin.is_none() {
290 for c in d
291 .candidates
292 .iter()
293 .filter(|c| c.eligible() && c.node_id != p.node_id)
294 {
295 if let Some(v) = &c.variant {
296 let ex = if c.tier == Some(Tier::Emulated) {
297 Execution::Emulated
298 } else {
299 Execution::Native
300 };
301 out.push((c.node_id.clone(), v.clone(), ex));
302 }
303 }
304 }
305 out
306 }
307
308 async fn dispatch(
309 &self,
310 place_timeout: Option<std::time::Duration>,
311 order: &PlaceOrder,
312 w: &VerifiedWorkload,
313 manifest: &str,
314 report: &mut PlaceReport,
315 ) {
316 let refs = w.revocation_refs();
317 for (node, variant, ex) in Self::dispatch_order(&report.decision) {
318 let attempt = |outcome: &str, code: Option<String>, reason: Option<String>| Attempt {
319 node_id: node.clone(),
320 variant: variant.clone(),
321 outcome: outcome.to_string(),
322 code,
323 reason,
324 ingest: None,
325 };
326 if ex == Execution::Emulated
327 && let GateVerdict::Deny { reason } = self.gate_verdict(w, &node, true)
328 {
329 report
330 .attempts
331 .push(attempt("gate_denied", None, Some(reason)));
332 continue;
333 }
334 let body = serde_json::to_value(PlaceBody {
335 name: w.id.clone(),
336 manifest_hash: manifest.to_string(),
337 variant: variant.clone(),
338 config: order.config.clone(),
339 start: order.start,
340 project_id: order.project_id.clone(),
341 })
342 .unwrap_or_default();
343 let target = match self.target(&node) {
344 Ok(t) => t,
345 Err(e) => {
346 report
347 .attempts
348 .push(attempt("unreachable", None, Some(e.to_string())));
349 continue;
350 }
351 };
352 let r = self
353 .round_trip(Call {
354 addr: &target.0.addr,
355 target: &node,
356 expect: Some(target.1),
357 method: method::PLACE,
358 decision_id: Some(report.decision_id.clone()),
359 body,
360 serve: true,
361 timeout: place_timeout,
362 })
363 .await;
364 let template = PlacementRecord {
365 instance_id: String::new(),
366 node_id: node.clone(),
367 kind: w.kind.clone(),
368 workload: w.id.clone(),
369 variant: variant.clone(),
370 decision_id: report.decision_id.clone(),
371 manifest_hash: manifest.to_string(),
372 project_id: order.project_id.clone(),
373 package_id: Some(refs.0.clone()),
374 signer_keys: refs.1.clone(),
375 artifact_hashes: refs.2.clone(),
376 };
377 match r {
378 Ok((result, _)) => {
379 let iid = result["instance_id"].as_str().unwrap_or_default();
380 let rec = PlacementRecord {
381 instance_id: iid.to_string(),
382 ..template
383 };
384 self.chain_event(
385 chain::EVENT_KIND_WORKLOAD_PLACE,
386 json!({ "phase": "placed", "decision_id": report.decision_id, "node": node,
387 "variant": variant, "emulated": ex == Execution::Emulated,
388 "instance_id": rec.instance_id, "attempts": report.attempts.len() + 1,
389 "target_result": result }),
390 );
391 if let Ok(mut p) = self.placements.lock() {
392 p.insert(rec.instance_id.clone(), rec.clone());
393 }
394 self.persist();
395 let mut a = attempt("placed", None, None);
396 a.ingest = result["ingest"].as_str().map(String::from);
397 report.attempts.push(a);
398 report.placed = Some(rec);
399 return;
400 }
401 Err(CallFailure::Indeterminate(why)) => {
402 let (settled, note) = self.reconcile(&node, &report.decision_id).await;
405 self.chain_event(
406 chain::EVENT_KIND_WORKLOAD_REFUSE,
407 json!({ "phase": "dispatch", "decision_id": report.decision_id, "node": node,
408 "variant": variant, "outcome": "indeterminate", "reason": why,
409 "reconcile": note, "next": if settled { "trying the next candidate" }
410 else { "stopped: the target may hold the instance" } }),
411 );
412 report.attempts.push(attempt(
413 "indeterminate",
414 None,
415 Some(format!("{why}; {note}")),
416 ));
417 if !settled {
418 self.remember_unsettled(template);
420 return;
421 }
422 }
423 Err(f) => {
424 let (outcome, code, reason) = match &f {
425 CallFailure::Refused(r) => (
426 "refused",
427 Some(r.code.as_str().to_string()),
428 r.reason.clone(),
429 ),
430 CallFailure::Unreachable(e) | CallFailure::Indeterminate(e) => {
431 ("unreachable", None, e.clone())
432 }
433 };
434 self.chain_event(
435 chain::EVENT_KIND_WORKLOAD_REFUSE,
436 json!({ "phase": "dispatch", "decision_id": report.decision_id, "node": node,
437 "variant": variant, "outcome": outcome, "code": code, "reason": reason,
438 "next": "trying the next candidate" }),
439 );
440 report.attempts.push(attempt(outcome, code, Some(reason)));
441 }
442 }
443 }
444 }
445
446 pub async fn instance(&self, m: &str, instance_id: &str) -> Result<Value, PlaneError> {
448 let find = || {
449 self.placements()
450 .into_iter()
451 .find(|r| r.instance_id == instance_id)
452 };
453 let rec = match find() {
454 Some(r) => r,
455 None => {
456 self.settle_unsettled().await;
458 find().ok_or_else(|| {
459 PlaneError::Unknown(format!("no placed instance {instance_id}"))
460 })?
461 }
462 };
463 if let Some(r) = self.seed_instance(&rec, m).await {
464 if m == method::UNLOAD
465 && r.is_ok()
466 && let Ok(mut p) = self.placements.lock()
467 {
468 p.remove(instance_id);
469 }
470 return r;
471 }
472 self.ensure_described(&rec.node_id).await;
473 let decision_id = if method::mutates(m) {
474 let (package_id, signer_keys, artifact_hashes) = rec.revocation_refs();
475 let ctx = json!({ "workload": {
476 "kind": rec.kind, "package_trust": "pinned_signer",
477 "node_tier": self.facts.get(&rec.node_id, now_ms() / 1000)
478 .map(|c| governance_tier(c.trust_tier))
479 .unwrap_or(crate::workload_governance::NodeTrustTier::Discovered),
480 "network": "egress", "resource_cost": 0.0,
481 "package_id": package_id, "signer_keys": signer_keys,
484 "artifact_hashes": artifact_hashes,
485 }});
486 let verdict = if method::is_teardown(m) {
490 self.gate.check_teardown(&self.node_id, m, &ctx)
491 } else {
492 self.gate.check(&self.node_id, m, &ctx)
493 };
494 if let GateDecision::Deny { reason, .. } = verdict {
495 return Err(PlaneError::Governance(reason));
496 }
497 Some(
498 self.chain_event(
499 crate::workload_governance::event_kind_for(m)
500 .unwrap_or(chain::EVENT_KIND_WORKLOAD_REFUSE),
501 json!({ "phase": "request", "instance_id": instance_id, "node": rec.node_id }),
502 ),
503 )
504 } else {
505 None
506 };
507 let body = serde_json::to_value(InstanceBody {
508 instance_id: Some(instance_id.to_string()),
509 grace_ms: None,
510 include_departed: false,
511 })
512 .unwrap_or_default();
513 let out = self.call(&rec.node_id, m, decision_id, body).await?;
514 if m == method::UNLOAD
515 && let Ok(mut p) = self.placements.lock()
516 {
517 p.remove(instance_id);
518 }
519 if m == method::UNLOAD {
520 self.drop_life(instance_id);
521 self.persist();
522 }
523 Ok(out)
524 }
525}
526
527pub fn render(r: &PlaceReport) -> String {
529 let mut s = explain(&r.decision);
530 s.push_str(&format!("decision_id: {}\n", r.decision_id));
531 if !r.attempts.is_empty() {
532 s.push_str("dispatch:\n");
533 for (i, a) in r.attempts.iter().enumerate() {
534 s.push_str(&format!(
535 " {}. {} via {}: {}{}\n",
536 i + 1,
537 a.node_id,
538 a.variant,
539 a.outcome,
540 a.reason
541 .as_deref()
542 .map(|x| format!(
543 " ({}{x})",
544 a.code
545 .as_deref()
546 .map(|c| format!("{c}: "))
547 .unwrap_or_default()
548 ))
549 .unwrap_or_default()
550 ));
551 if a.ingest.as_deref() == Some("disabled") {
552 s.push_str(
553 " placed with ingest disabled: the cog has no token and its vectors go nowhere\n",
554 );
555 }
556 }
557 }
558 s
559}
560
561impl PlaceOrder {
562 pub fn check(&self) -> Result<(), PlaneError> {
564 self.config
565 .mode
566 .validate()
567 .map_err(|e| PlaneError::Invalid(e.to_string()))?;
568 if self.config.csi_port == 0 {
569 return Err(PlaneError::Invalid("csi_port must be non-zero".into()));
570 }
571 Ok(())
572 }
573}