1use 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 version: String,
49 installed_here: bool,
50 console_commands: Vec<String>,
51 last: Option<RunEvidence>,
52}
53
54pub 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 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 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 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 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 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 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 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 fn network_exposure(&self) -> NetworkPolicy {
414 NetworkPolicy::Egress
415 }
416
417 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 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}