Skip to main content

clawft_kernel/workload_runtime/
logical.rs

1//! The `logical` adapter: a per-project child kernel as a workload
2//! (ADR-103 A6, Phase 2 package G).
3//!
4//! `logical` owns no process. It admits a [`WorkloadSource::Project`]
5//! workload and delegates the OS side to a [`ChildLauncher`] that the weave
6//! supervisor implements, so the kernel crate never depends on weave. Every
7//! transition still goes through [`super::WorkloadHost`], i.e. the workload
8//! gate and the chain.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use clawft_types::placement::{AttrValue, Capability, CapabilityId, Provenance};
17use tokio::sync::Mutex;
18
19use super::evidence::RunEvidence;
20use super::types::{
21    Admission, ControlMode, InstanceHandle, InstanceState, InstanceStatus, RuntimeError,
22    VerifiedWorkload, WorkloadConfig, WorkloadRuntime, WorkloadSource,
23};
24use crate::workload_governance::NetworkPolicy;
25
26/// Adapter id.
27pub const LOGICAL_ID: &str = "logical";
28/// The capability the adapter advertises and the `project` kind requires.
29pub const CAP_PROJECT_LOGICAL: &str = "runtime.project.logical";
30
31/// What a launcher needs to start one child kernel.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ChildSpec {
34    /// Immutable adapter selection; restart cannot reinterpret the manifest.
35    pub adapter: String,
36    /// Project id (ULID).
37    pub project_id: String,
38    /// Canonical project root.
39    pub root: PathBuf,
40    /// Key id of the project key the certificate names.
41    pub key_id: String,
42    /// Serial of the certificate in force.
43    pub cert_serial: u64,
44}
45
46/// A running (or adopted) child.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ChildRef {
49    /// Project id.
50    pub project_id: String,
51    /// Identity selected by the launcher; a PID is never proof of a container.
52    pub identity: ChildIdentity,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ChildIdentity {
57    Native { host_pid: u32 },
58    Container { engine: String, immutable_container_id: String, host_pid: u32 },
59}
60
61impl ChildIdentity {
62    pub fn host_pid(&self) -> u32 {
63        match self { Self::Native { host_pid } | Self::Container { host_pid, .. } => *host_pid }
64    }
65}
66
67/// What a launcher can say about a child.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ChildProbe {
70    /// Alive.
71    Running {
72        identity: ChildIdentity,
73    },
74    /// Gone, with how it ended.
75    Exited {
76        /// Exit code, when it exited normally.
77        code: Option<i32>,
78        /// Signal, when it was killed.
79        signal: Option<i32>,
80    },
81    /// Engine identity or liveness could not be established. Callers must
82    /// neither report readiness nor start a replacement from this state.
83    Unverifiable { reason: String },
84    /// No child was ever started for this project.
85    NotStarted,
86}
87
88/// The OS side of the `logical` adapter.
89#[async_trait]
90pub trait ChildLauncher: Send + Sync {
91    /// Launch a child kernel for `spec`. Refuses a project that already has
92    /// a live child.
93    async fn spawn(&self, spec: &ChildSpec) -> Result<ChildRef, RuntimeError>;
94    /// Stop it: graceful shutdown first, then terminate after `grace`.
95    /// Returns the exit code when known.
96    async fn terminate(&self, child: &ChildRef, grace: Duration)
97    -> Result<Option<i32>, RuntimeError>;
98    /// Current state of the project's child.
99    async fn probe(&self, project_id: &str) -> ChildProbe;
100}
101
102struct Instance {
103    spec: ChildSpec,
104    child: Option<ChildRef>,
105}
106
107/// The adapter.
108pub struct LogicalRuntime {
109    adapter: &'static str,
110    capability: &'static str,
111    launcher: Arc<dyn ChildLauncher>,
112    instances: Mutex<HashMap<String, Instance>>,
113}
114
115impl LogicalRuntime {
116    /// Persistent Wasmtime project lifecycle, using a dedicated runner launcher.
117    /// Shares process bookkeeping only; admission, capability and evidence carry
118    /// the explicit Wasmtime identity and reject logical payloads.
119    pub fn wasmtime_project(launcher: Arc<dyn ChildLauncher>) -> Self {
120        Self {
121            adapter: "wasmtime-project-v1",
122            capability: "runtime.project.wasmtime",
123            launcher,
124            instances: Mutex::new(HashMap::new()),
125        }
126    }
127
128    /// An adapter over `launcher`.
129    pub fn new(launcher: Arc<dyn ChildLauncher>) -> Self {
130        Self {
131            adapter: LOGICAL_ID,
132            capability: CAP_PROJECT_LOGICAL,
133            launcher,
134            instances: Mutex::new(HashMap::new()),
135        }
136    }
137}
138
139fn instance_id(project_id: &str) -> String {
140    format!("project-{project_id}")
141}
142
143fn spec_of(w: &VerifiedWorkload) -> Result<ChildSpec, RuntimeError> {
144    match &w.source {
145        WorkloadSource::Project(p) => Ok(ChildSpec {
146            adapter: p.adapter.clone(),
147            project_id: p.project_id.clone(),
148            root: p.root.clone(),
149            key_id: p.key_id.clone(),
150            cert_serial: p.cert_serial,
151        }),
152        _ => Err(RuntimeError::AdmissionRefused(format!(
153            "{LOGICAL_ID} runs only project workloads"
154        ))),
155    }
156}
157
158#[async_trait]
159impl WorkloadRuntime for LogicalRuntime {
160    fn id(&self) -> &str {
161        self.adapter
162    }
163
164    fn provides(&self) -> Vec<Capability> {
165        let Ok(id) = CapabilityId::new(self.capability) else {
166            return Vec::new();
167        };
168        vec![Capability::new(id, Provenance::Probed).with_attr("os", AttrValue::from(std::env::consts::OS))]
169    }
170
171    async fn admit(&self, w: &VerifiedWorkload) -> Result<Admission, RuntimeError> {
172        let spec = spec_of(w)?;
173        if spec.adapter != self.adapter {
174            return Err(RuntimeError::AdmissionRefused("project adapter mismatch".into()));
175        }
176        if !spec.root.is_dir() {
177            return Err(RuntimeError::AdmissionRefused(format!(
178                "project root {} is gone",
179                spec.root.display()
180            )));
181        }
182        Ok(Admission {
183            runtime: self.adapter.into(),
184            arch: if self.adapter == LOGICAL_ID {
185                std::env::consts::ARCH
186            } else {
187                "wasm32-wasip1"
188            }
189            .into(),
190            emulated: false,
191            notes: vec![format!("project {} root {}", spec.project_id, spec.root.display())],
192        })
193    }
194
195    async fn load(
196        &self,
197        w: &VerifiedWorkload,
198        _cfg: &WorkloadConfig,
199    ) -> Result<InstanceHandle, RuntimeError> {
200        self.admit(w).await?;
201        let spec = spec_of(w)?;
202        let iid = instance_id(&spec.project_id);
203        let mut map = self.instances.lock().await;
204        if map.contains_key(&iid) {
205            return Err(RuntimeError::InvalidState(format!("{iid} is already loaded")));
206        }
207        let workload_id = spec.project_id.clone();
208        map.insert(iid.clone(), Instance { spec, child: None });
209        Ok(InstanceHandle {
210            runtime: self.adapter.into(),
211            instance_id: iid,
212            workload_id,
213            store_installed: false,
214        })
215    }
216
217    async fn start(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
218        let mut map = self.instances.lock().await;
219        let inst = map
220            .get_mut(&h.instance_id)
221            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
222        if matches!(
223            self.launcher.probe(&inst.spec.project_id).await,
224            ChildProbe::Running { .. } | ChildProbe::Unverifiable { .. }
225        ) {
226            return Err(RuntimeError::InvalidState("already running".into()));
227        }
228        if !inst.spec.root.is_dir() {
229            return Err(RuntimeError::AdmissionRefused(format!(
230                "project root {} is gone",
231                inst.spec.root.display()
232            )));
233        }
234        inst.child = Some(self.launcher.spawn(&inst.spec).await?);
235        Ok(())
236    }
237
238    async fn stop(&self, h: &InstanceHandle, grace: Duration) -> Result<RunEvidence, RuntimeError> {
239        let mut map = self.instances.lock().await;
240        let inst = map
241            .get_mut(&h.instance_id)
242            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
243        let started = std::time::Instant::now();
244        let mut ev = RunEvidence {
245            runtime: self.adapter.into(),
246            instance_id: h.instance_id.clone(),
247            ..RunEvidence::default()
248        };
249        // An adopted child was not started by this instance: stop whatever
250        // the launcher says is running for the project.
251        let child = match inst.child.take() {
252            Some(c) => Some(c),
253            None => match self.launcher.probe(&inst.spec.project_id).await {
254                ChildProbe::Running { identity } => Some(ChildRef {
255                    project_id: inst.spec.project_id.clone(),
256                    identity,
257                }),
258                ChildProbe::Unverifiable { reason } => {
259                    return Err(RuntimeError::InvalidState(format!("child unverifiable: {reason}")));
260                }
261                _ => None,
262            },
263        };
264        if let Some(child) = child {
265            ev.exit_code = self.launcher.terminate(&child, grace).await?;
266        }
267        ev.elapsed_ms = started.elapsed().as_millis() as u64;
268        Ok(ev)
269    }
270
271    async fn unload(&self, h: InstanceHandle) -> Result<(), RuntimeError> {
272        let mut map = self.instances.lock().await;
273        let inst = map
274            .get(&h.instance_id)
275            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
276        if matches!(
277            self.launcher.probe(&inst.spec.project_id).await,
278            ChildProbe::Running { .. } | ChildProbe::Unverifiable { .. }
279        ) {
280            return Err(RuntimeError::InvalidState("stop the child before unloading".into()));
281        }
282        map.remove(&h.instance_id);
283        Ok(())
284    }
285
286    async fn status(&self, h: &InstanceHandle) -> InstanceStatus {
287        let map = self.instances.lock().await;
288        let Some(inst) = map.get(&h.instance_id) else {
289            return InstanceStatus::of(InstanceState::Unknown);
290        };
291        match self.launcher.probe(&inst.spec.project_id).await {
292            ChildProbe::Running { identity } => InstanceStatus {
293                state: InstanceState::Running,
294                exit_code: None,
295                detail: Some(match identity {
296                    ChildIdentity::Native { host_pid } => format!("pid {host_pid}"),
297                    ChildIdentity::Container { engine, immutable_container_id, .. } => format!("{engine} container {immutable_container_id}"),
298                }),
299            },
300            ChildProbe::Exited { code, signal } => InstanceStatus {
301                state: InstanceState::Exited,
302                exit_code: code,
303                detail: signal.map(|s| format!("signal {s}")),
304            },
305            ChildProbe::Unverifiable { reason } => InstanceStatus {
306                state: InstanceState::Unknown,
307                exit_code: None,
308                detail: Some(reason),
309            },
310            ChildProbe::NotStarted => InstanceStatus::of(InstanceState::Loaded),
311        }
312    }
313
314    fn control_mode(&self) -> ControlMode {
315        ControlMode::Managed
316    }
317
318    fn network_exposure(&self) -> NetworkPolicy {
319        NetworkPolicy::None
320    }
321
322    async fn console(&self, _h: &InstanceHandle, _command: &str) -> Result<RunEvidence, RuntimeError> {
323        Err(RuntimeError::Unsupported("a project kernel has no console cycle".into()))
324    }
325}