Skip to main content

clawft_kernel/workload_runtime/
seed.rs

1//! `remote.api` adapter for a Cognitum Seed (ADR-100 section 5, option b):
2//! drives the Seed's own HTTP API instead of running WeftOS on it.
3//!
4//! Seed constraints honored here:
5//! - only store cogs the operator pinned by id and version install
6//!   (our signed packages go to nodes we control, never the store path);
7//! - at most [`SEED_CONCURRENCY_CAP`] cogs run at once;
8//! - installed cogs auto-start, so `load` stops a cog it just installed and
9//!   `start` is the explicit transition;
10//! - a console run needs the feed to itself (UDP 5006 contention): the
11//!   adapter lists running cogs as [`Preemption`]s, the host gates and
12//!   stops each, runs the console cycle, then restarts them;
13//! - `unload` uninstalls only a cog this adapter installed.
14//!
15//! Governance runs in WeftOS at the adapter ([`super::WorkloadHost`]).
16
17use std::collections::HashMap;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use async_trait::async_trait;
22use clawft_types::placement::{AttrValue, Capability, CapabilityId, Provenance};
23use serde_json::{Value, json};
24use tokio::sync::Mutex;
25
26#[path = "seed_install.rs"]
27mod install;
28
29use super::evidence::RunEvidence;
30use super::seed_http::{Method, SeedCredentials, SeedTransport};
31use super::types::{
32    Admission, ControlMode, InstanceHandle, InstanceState, InstanceStatus, LinkSecurity,
33    Preemption, RuntimeError, VerifiedWorkload, WorkloadConfig, WorkloadRuntime, WorkloadSource,
34};
35use crate::workload_governance::NetworkPolicy;
36use crate::workload_pkg::manifest::{valid_cog_id, valid_token};
37
38use super::seed_types::lines;
39pub use super::seed_types::{
40    API_TIMEOUT, CONSOLE_TIMEOUT, InstalledCog, LOG_LINES, SEED_CONCURRENCY_CAP, SEED_ID,
41    SEED_REGISTRY, SeedConfig, SeedPin,
42};
43use install::pinned_on_seed;
44
45struct Instance {
46    cog_id: String,
47    /// The operator-pinned version this instance may run.
48    version: String,
49    installed_here: bool,
50    console_commands: Vec<String>,
51    last: Option<RunEvidence>,
52}
53
54/// The Seed runtime adapter.
55pub struct SeedApiRuntime {
56    pub(super) cfg: SeedConfig,
57    pub(super) transport: Arc<dyn SeedTransport>,
58    pub(super) creds: Arc<dyn SeedCredentials>,
59    instances: Mutex<HashMap<String, Instance>>,
60}
61
62impl SeedApiRuntime {
63    /// New adapter. Refuses a malformed node id or pin.
64    pub fn new(
65        cfg: SeedConfig,
66        transport: Arc<dyn SeedTransport>,
67        creds: Arc<dyn SeedCredentials>,
68    ) -> Result<Self, RuntimeError> {
69        if !valid_token(&cfg.node_id, 64) {
70            return Err(RuntimeError::InvalidConfig(
71                "seed node id must be a plain token".into(),
72            ));
73        }
74        for p in &cfg.pins {
75            VerifiedWorkload::store_pin(SEED_REGISTRY, &p.id, &p.version, p.sha256.as_deref())?;
76        }
77        Ok(Self {
78            cfg,
79            transport,
80            creds,
81            instances: Mutex::new(HashMap::new()),
82        })
83    }
84
85    fn pin_for(&self, w: &VerifiedWorkload) -> Result<&SeedPin, RuntimeError> {
86        let WorkloadSource::StorePin { registry, sha256 } = &w.source else {
87            return Err(RuntimeError::AdmissionRefused(
88                "the Seed store path installs only operator-pinned store cogs; \
89                 signed packages go to native or container nodes"
90                    .into(),
91            ));
92        };
93        if registry != SEED_REGISTRY {
94            return Err(RuntimeError::AdmissionRefused(format!(
95                "unknown registry {registry:?}"
96            )));
97        }
98        let pin = self
99            .cfg
100            .pins
101            .iter()
102            .find(|p| p.id == w.id && p.version == w.version)
103            .ok_or_else(|| {
104                RuntimeError::AdmissionRefused(format!(
105                    "{}@{} is not pinned by the operator for this Seed",
106                    w.id, w.version
107                ))
108            })?;
109        if let (Some(a), Some(b)) = (sha256, &pin.sha256)
110            && a != b
111        {
112            return Err(RuntimeError::AdmissionRefused(
113                "store pin sha256 differs from the operator pin".into(),
114            ));
115        }
116        Ok(pin)
117    }
118
119    async fn instance_pin(&self, h: &InstanceHandle) -> Result<(String, String), RuntimeError> {
120        self.instances
121            .lock()
122            .await
123            .get(&h.instance_id)
124            .map(|i| (i.cog_id.clone(), i.version.clone()))
125            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))
126    }
127
128    async fn instance_cog(&self, h: &InstanceHandle) -> Result<String, RuntimeError> {
129        self.instances
130            .lock()
131            .await
132            .get(&h.instance_id)
133            .map(|i| i.cog_id.clone())
134            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))
135    }
136}
137
138#[async_trait]
139impl WorkloadRuntime for SeedApiRuntime {
140    fn id(&self) -> &str {
141        SEED_ID
142    }
143
144    fn provides(&self) -> Vec<Capability> {
145        // Adapter-attested: provenance `claimed`, never `probed`.
146        let mut out = Vec::new();
147        if let Ok(id) = CapabilityId::new("runtime.remote.api") {
148            out.push(
149                Capability::new(id, Provenance::Claimed)
150                    .with_attr("vendor", "cognitum")
151                    .with_attr("device", "seed")
152                    .with_attr("concurrency_cap", self.cfg.concurrency_cap as i64)
153                    .with_attr("arches_native", AttrValue::List(vec!["armv7".into()])),
154            );
155        }
156        if let Ok(id) = CapabilityId::new("node.class.cognitum-seed") {
157            out.push(Capability::new(id, Provenance::Claimed));
158        }
159        out
160    }
161
162    async fn admit(&self, w: &VerifiedWorkload) -> Result<Admission, RuntimeError> {
163        let pin = self.pin_for(w)?.clone();
164        let store = self
165            .api(Method::Get, "/api/v1/apps/available", None, API_TIMEOUT)
166            .await?;
167        let entry = store
168            .get("cogs")
169            .and_then(Value::as_array)
170            .and_then(|c| {
171                c.iter()
172                    .find(|e| e.get("id").and_then(Value::as_str) == Some(&w.id))
173            })
174            .ok_or_else(|| {
175                RuntimeError::AdmissionRefused(format!(
176                    "the Seed store registry does not list {}",
177                    w.id
178                ))
179            })?;
180        let version = entry.get("version").and_then(Value::as_str).unwrap_or("");
181        if version != pin.version {
182            return Err(RuntimeError::AdmissionRefused(format!(
183                "store has {}@{version}, operator pinned {}",
184                w.id, pin.version
185            )));
186        }
187        if let Some(want) = &pin.sha256
188            && entry.get("sha256").and_then(Value::as_str) != Some(want.as_str())
189        {
190            return Err(RuntimeError::AdmissionRefused(
191                "store sha256 differs from the pin".into(),
192            ));
193        }
194        Ok(Admission {
195            runtime: SEED_ID.into(),
196            arch: "armv7".into(),
197            emulated: false,
198            notes: vec![format!("store {}@{version} matches operator pin", w.id)],
199        })
200    }
201
202    async fn load(
203        &self,
204        w: &VerifiedWorkload,
205        cfg: &WorkloadConfig,
206    ) -> Result<InstanceHandle, RuntimeError> {
207        if !cfg.args.is_empty() {
208            return Err(RuntimeError::InvalidConfig(
209                "Seed cogs take their settings from the Seed config API, not argv".into(),
210            ));
211        }
212        let pin = self.pin_for(w)?.clone();
213        let iid = format!("{}-seed-{}", w.id, cfg.node_id);
214        if !valid_cog_id(&w.id) || !valid_token(&iid, 160) {
215            return Err(RuntimeError::InvalidConfig("bad instance id".into()));
216        }
217        if self.instances.lock().await.contains_key(&iid) {
218            return Err(RuntimeError::InvalidState(format!(
219                "{iid} is already loaded"
220            )));
221        }
222        let installed = self.installed().await?;
223        let installed_here = match installed.iter().find(|c| c.id == w.id) {
224            Some(c) if c.version == pin.version => false,
225            Some(c) => {
226                return Err(RuntimeError::AdmissionRefused(format!(
227                    "{} is installed at {} but the pin is {}",
228                    w.id, c.version, pin.version
229                )));
230            }
231            None => {
232                self.admit(w).await?;
233                // Installed cogs auto-start, so an install is a start too:
234                // it must fit under the concurrency cap.
235                let running = installed.iter().filter(|c| c.running).count();
236                if running >= self.cfg.concurrency_cap {
237                    return Err(RuntimeError::AdmissionRefused(format!(
238                        "installing {} auto-starts it, but the Seed concurrency cap {} \
239                         is reached ({running} running)",
240                        w.id, self.cfg.concurrency_cap
241                    )));
242                }
243                self.install_reconciled(&w.id).await?;
244                // The install call names only the id and the Seed starts
245                // what it installs (the documented contract; there is no
246                // way to install without starting), so an unpinned version
247                // could be running now. Read the Seed first: anything but
248                // the pinned version is stopped and removed at once.
249                let after = match self.installed().await {
250                    Ok(list) => list,
251                    Err(e) => {
252                        return Err(self.undo_install(w, &iid, &pin, "version check", e).await);
253                    }
254                };
255                if let Err(e) = pinned_on_seed(&after, &w.id, &pin.version) {
256                    return Err(self.undo_install(w, &iid, &pin, "version check", e).await);
257                }
258                // Pinned: loading must not leave it running.
259                if let Err(e) = self.stop_cog(&w.id).await {
260                    return Err(self.undo_install(w, &iid, &pin, "stop", e).await);
261                }
262                true
263            }
264        };
265        self.instances.lock().await.insert(
266            iid.clone(),
267            Instance {
268                cog_id: w.id.clone(),
269                version: pin.version.clone(),
270                installed_here,
271                console_commands: pin.console_commands.clone(),
272                last: None,
273            },
274        );
275        Ok(InstanceHandle {
276            runtime: SEED_ID.into(),
277            instance_id: iid,
278            workload_id: w.id.clone(),
279            store_installed: installed_here,
280        })
281    }
282
283    async fn adopt(&self, h: &InstanceHandle, w: &VerifiedWorkload) -> Result<(), RuntimeError> {
284        let pin = self.pin_for(w)?.clone();
285        let expect = format!("{}-seed-{}", w.id, self.cfg.node_id);
286        if h.runtime != SEED_ID || h.workload_id != w.id || h.instance_id != expect {
287            return Err(RuntimeError::InvalidConfig(format!(
288                "{} is not a handle of this Seed adapter",
289                h.instance_id
290            )));
291        }
292        if self.instances.lock().await.contains_key(&h.instance_id) {
293            return Ok(());
294        }
295        // The Seed is the source of truth: it must still hold the cog at
296        // the pinned version.
297        pinned_on_seed(&self.installed().await?, &w.id, &pin.version)?;
298        self.instances.lock().await.insert(
299            h.instance_id.clone(),
300            Instance {
301                cog_id: w.id.clone(),
302                version: pin.version.clone(),
303                installed_here: h.store_installed,
304                console_commands: pin.console_commands.clone(),
305                last: None,
306            },
307        );
308        Ok(())
309    }
310
311    async fn start(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
312        let (id, version) = self.instance_pin(h).await?;
313        let installed = self.installed().await?;
314        pinned_on_seed(&installed, &id, &version)?;
315        if installed.iter().any(|c| c.id == id && c.running) {
316            return Ok(());
317        }
318        let running = installed.iter().filter(|c| c.running).count();
319        if running >= self.cfg.concurrency_cap {
320            return Err(RuntimeError::AdmissionRefused(format!(
321                "Seed concurrency cap {} reached ({running} running)",
322                self.cfg.concurrency_cap
323            )));
324        }
325        self.start_cog(&id).await
326    }
327
328    async fn stop(
329        &self,
330        h: &InstanceHandle,
331        _grace: Duration,
332    ) -> Result<RunEvidence, RuntimeError> {
333        let id = self.instance_cog(h).await?;
334        let t0 = Instant::now();
335        self.stop_cog(&id).await?;
336        let logs = self
337            .api(
338                Method::Get,
339                &format!("/api/v1/apps/{id}/logs?lines={LOG_LINES}"),
340                None,
341                API_TIMEOUT,
342            )
343            .await
344            .unwrap_or(Value::Null);
345        let (out, err) = (lines(logs.get("output")), lines(logs.get("errors")));
346        let ev = RunEvidence {
347            runtime: SEED_ID.into(),
348            instance_id: h.instance_id.clone(),
349            elapsed_ms: t0.elapsed().as_millis() as u64,
350            stdout_bytes: out.len() as u64,
351            stderr_bytes: err.len() as u64,
352            stdout: out,
353            stderr: err,
354            ..RunEvidence::default()
355        };
356        if let Some(i) = self.instances.lock().await.get_mut(&h.instance_id) {
357            i.last = Some(ev.clone());
358        }
359        Ok(ev)
360    }
361
362    async fn unload(&self, h: InstanceHandle) -> Result<(), RuntimeError> {
363        let (cog, installed_here) = self
364            .instances
365            .lock()
366            .await
367            .get(&h.instance_id)
368            .map(|i| (i.cog_id.clone(), i.installed_here))
369            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
370        if installed_here {
371            // Forgotten only once the uninstall succeeded, so a failed one
372            // can be retried.
373            self.api(
374                Method::Delete,
375                &format!("/api/v1/apps/{cog}"),
376                None,
377                API_TIMEOUT,
378            )
379            .await?;
380        }
381        self.instances.lock().await.remove(&h.instance_id);
382        Ok(())
383    }
384
385    async fn status(&self, h: &InstanceHandle) -> InstanceStatus {
386        let Ok(id) = self.instance_cog(h).await else {
387            return InstanceStatus::of(InstanceState::Unknown);
388        };
389        match self.installed().await {
390            Ok(list) => match list.iter().find(|c| c.id == id) {
391                Some(c) if c.running => InstanceStatus::of(InstanceState::Running),
392                Some(_) => InstanceStatus::of(InstanceState::Loaded),
393                None => InstanceStatus::of(InstanceState::Unknown),
394            },
395            Err(e) => InstanceStatus {
396                state: InstanceState::Degraded,
397                exit_code: None,
398                detail: Some(e.to_string()),
399            },
400        }
401    }
402
403    fn control_mode(&self) -> ControlMode {
404        ControlMode::Managed
405    }
406
407    fn link_security(&self) -> LinkSecurity {
408        self.transport.link_security()
409    }
410
411    /// The Seed firmware, not WeftOS, decides what its cogs can reach; the
412    /// Seed has internet access, so the gate is told `egress`.
413    fn network_exposure(&self) -> NetworkPolicy {
414        NetworkPolicy::Egress
415    }
416
417    /// Every running cog, including ones WeftOS did not load (UDP 5006
418    /// contention: any listener on the Seed competes for the feed).
419    async fn console_preemptions(
420        &self,
421        h: &InstanceHandle,
422    ) -> Result<Vec<Preemption>, RuntimeError> {
423        self.instance_cog(h).await?;
424        Ok(self
425            .installed()
426            .await?
427            .into_iter()
428            .filter(|c| c.running)
429            .map(|c| Preemption {
430                registry: SEED_REGISTRY.into(),
431                workload_id: c.id,
432                version: c.version,
433            })
434            .collect())
435    }
436
437    async fn preempt(&self, p: &Preemption) -> Result<(), RuntimeError> {
438        self.stop_cog(&p.workload_id).await
439    }
440
441    async fn resume(&self, p: &Preemption) -> Result<(), RuntimeError> {
442        self.start_cog(&p.workload_id).await
443    }
444
445    async fn console(
446        &self,
447        h: &InstanceHandle,
448        command: &str,
449    ) -> Result<RunEvidence, RuntimeError> {
450        let (id, allowed) = {
451            let map = self.instances.lock().await;
452            let i = map
453                .get(&h.instance_id)
454                .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
455            (i.cog_id.clone(), i.console_commands.clone())
456        };
457        if !allowed.iter().any(|c| c == command) {
458            return Err(RuntimeError::InvalidConfig(format!(
459                "console command {command:?} is not pinned"
460            )));
461        }
462        // UDP 5006 contention: the host preempts running cogs (each gated
463        // as `workload.stop`) before calling this; never stop them here.
464        // An unpinned version never runs, console included: the Seed may
465        // have been upgraded or reinstalled since `load`.
466        let list = self.installed().await?;
467        let version = self
468            .instances
469            .lock()
470            .await
471            .get(&h.instance_id)
472            .map(|i| i.version.clone())
473            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
474        pinned_on_seed(&list, &id, &version)?;
475        let running: Vec<String> = list
476            .into_iter()
477            .filter(|c| c.running)
478            .map(|c| c.id)
479            .collect();
480        if !running.is_empty() {
481            return Err(RuntimeError::InvalidState(format!(
482                "cogs still running on the Seed ({}); a console run needs the feed to itself",
483                running.join(", ")
484            )));
485        }
486        let t0 = Instant::now();
487        let v = self
488            .api(
489                Method::Post,
490                &format!("/api/v1/apps/{id}/console"),
491                Some(&json!({ "command": command })),
492                CONSOLE_TIMEOUT,
493            )
494            .await?;
495        let out = ["output", "stdout"]
496            .iter()
497            .map(|k| lines(v.get(*k)))
498            .find(|s| !s.is_empty())
499            .unwrap_or_else(|| v.to_string());
500        let err = ["errors", "stderr"]
501            .iter()
502            .map(|k| lines(v.get(*k)))
503            .find(|s| !s.is_empty())
504            .unwrap_or_default();
505        let code = ["exit_code", "code"]
506            .iter()
507            .find_map(|k| v.get(*k).and_then(Value::as_i64))
508            .map(|c| c as i32);
509        Ok(RunEvidence {
510            runtime: SEED_ID.into(),
511            instance_id: h.instance_id.clone(),
512            args: command.split_whitespace().map(str::to_string).collect(),
513            exit_code: code,
514            elapsed_ms: t0.elapsed().as_millis() as u64,
515            stdout_bytes: out.len() as u64,
516            stderr_bytes: err.len() as u64,
517            stdout: out,
518            stderr: err,
519            ..RunEvidence::default()
520        })
521    }
522}