Skip to main content

clawft_kernel/workload_ctl/
plane_place.rs

1//! Placement and instance control on the controller (ADR-099 sections 3,
2//! 4 and 7).
3//!
4//! `place`:
5//!
6//! 1. verify and seed the signed package (this node serves it to the
7//!    target during the call), derive the kind's spec (ADR-100 s2);
8//! 2. ask the gate `workload.place` for every cached node (each check is
9//!    chained by the gate) and run the pure engine on the verified facts;
10//! 3. chain the decision (candidates, scores, failed constraints) as
11//!    `workload.place`; its chain hash is the decision id every request
12//!    carries;
13//! 4. send the signed `place` to the winner, then to the next eligible
14//!    candidates in rank order while targets refuse (the target's
15//!    admission self-check disagreeing with its advertised facts, its own
16//!    governance, a fetch or verify failure); each refusal is chained as
17//!    `workload.refuse`; the final placement is chained as `workload.place`.
18
19use 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/// What the operator asked for.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PlaceOrder {
45    /// Unpacked signed package directory.
46    pub package_dir: PathBuf,
47    /// Instance configuration.
48    pub config: CtlConfig,
49    /// Place on this node or fail naming the constraint.
50    #[serde(default)]
51    pub pin: Option<String>,
52    /// Affinity.
53    #[serde(default)]
54    pub prefer: Vec<String>,
55    /// Anti-affinity.
56    #[serde(default)]
57    pub avoid: Vec<String>,
58    /// Operator opt-in to emulation (never automatic).
59    #[serde(default)]
60    pub allow_emulated: bool,
61    /// Start after loading.
62    #[serde(default = "yes")]
63    pub start: bool,
64    /// Decide and explain only; dispatch nothing.
65    #[serde(default)]
66    pub dry_run: bool,
67    /// Project the workload is placed for. The target's ingest bridge
68    /// delivers a cog's vectors to this project's store; `None` delivers
69    /// them to the placing controller's store. The target refuses the order
70    /// unless this controller may place for the project (it is the target
71    /// node itself, is listed for the project in the target's
72    /// `cog-ingest.json`, or is the node of the project's bound key); the
73    /// controller's signature covers the id but does not by itself prove it.
74    #[serde(default)]
75    pub project_id: Option<String>,
76}
77
78fn yes() -> bool {
79    true
80}
81
82/// One dispatch attempt.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct Attempt {
85    /// Target.
86    pub node_id: String,
87    /// Variant sent.
88    pub variant: String,
89    /// `placed`, `refused`, `unreachable`, `indeterminate` or `gate_denied`.
90    pub outcome: String,
91    /// Refusal code, if refused.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub code: Option<String>,
94    /// Why, if not placed.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub reason: Option<String>,
97    /// The target's ingest state for a placed cog (`enabled`, `disabled` or
98    /// `none`). `disabled`: placed without a token, its vectors go nowhere.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub ingest: Option<String>,
101}
102
103/// Result of `place` (and of a dry run).
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct PlaceReport {
106    /// The engine's decision.
107    pub decision: Decision,
108    /// Chain hash of the decision event.
109    pub decision_id: String,
110    /// Human-readable explanation (engine explain plus attempts).
111    pub explain: String,
112    /// Dispatch attempts, in order.
113    pub attempts: Vec<Attempt>,
114    /// The placement, if any target accepted.
115    pub placed: Option<PlacementRecord>,
116    /// The workload needs something attached to its node (a sensor, a
117    /// device, an accelerator), so the lifecycle never moves it by itself.
118    #[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        // Every workload names something the revocation list can be checked
130        // against (a store pin or project gets a synthetic package id): the
131        // gate refuses a request that names nothing.
132        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            // A native cog has the host's network (ADR-100 s4): say so.
140            "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    /// Decide (and unless `dry_run`, dispatch) one placement, with the
186    /// default lifecycle policy (see [`Self::place_with`]).
187    pub async fn place(&self, order: &PlaceOrder) -> Result<PlaceReport, PlaneError> {
188        self.place_with(order, LifecyclePolicy::default()).await
189    }
190
191    /// [`Self::place`], recording what the lifecycle needs to reschedule the
192    /// instance if its node is lost: the order, and `policy` (whether it may
193    /// move at all).
194    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    /// [`Self::place_unregistered`] with a shorter `place` timeout.
215    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    /// Winner first, then the other eligible candidates in rank order (a
285    /// pin allows only the pinned node).
286    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                    // The target may have placed it: reconcile before any
403                    // other candidate, or stop (never two instances).
404                    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                        // Adopted later if the target turns out to hold it.
419                        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    /// Gate an instance transition on the controller (chained) and send it.
447    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                // It may come from a lost `place` answer: adopt, then retry.
457                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                // A start names the package so the revocation list can be
482                // checked; a stop or unload is never blocked by it.
483                "package_id": package_id, "signer_keys": signer_keys,
484                "artifact_hashes": artifact_hashes,
485            }});
486            // Stop and unload only shrink what is running: a denial caused
487            // by the node's trust tier alone (a demoted peer) is waived by
488            // the gate and chained there; any other denial stands.
489            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
527/// Engine explanation plus the dispatch attempts.
528pub 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    /// Boundary validation.
563    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}