1use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::RwLock;
22use std::time::{Duration, Instant};
23
24use async_trait::async_trait;
25use clawft_types::placement::Capability;
26use tokio::sync::Mutex;
27use tokio::task::JoinHandle;
28
29use super::capabilities;
30use super::config::{InferConfig, InferMode, ManagedConfig};
31use super::exposure::port_in_use;
32use super::launch::{Launch, build_launch, check_format};
33use super::ollama::SharedLoad;
34use super::probe::{ServerClient, ServerReport};
35use super::spec::{InferFlavor, InferenceSpec};
36use crate::model_manifest::{ModelError, ResolvedModel};
37use crate::workload_governance::NetworkPolicy;
38use crate::workload_pkg::manifest::valid_token;
39use crate::workload_runtime::evidence::RunEvidence;
40use crate::workload_runtime::supervise::Supervised;
41use crate::workload_runtime::types::{
42 Admission, ControlMode, InstanceHandle, InstanceStatus, RuntimeError, VerifiedWorkload,
43 WorkloadConfig, WorkloadRuntime,
44};
45
46pub(super) enum ManagedPlan {
48 Process(Launch),
50 Ollama { tag: String },
52}
53
54pub(super) struct Managed {
56 pub plan: ManagedPlan,
57 pub model_name: String,
59 pub proc: Option<Supervised>,
60 pub wanted: bool,
62 pub restarts: u32,
63 pub exited_at: Option<Instant>,
64 pub started_at: Option<Instant>,
66 pub last: Option<RunEvidence>,
67 pub exposed: Option<Vec<std::net::IpAddr>>,
70 pub load: Option<(SharedLoad, JoinHandle<()>)>,
71 pub we_loaded: std::sync::Arc<std::sync::atomic::AtomicBool>,
74}
75
76pub(super) struct Instance {
78 pub spec: InferenceSpec,
79 pub client: ServerClient,
80 pub managed: Option<Managed>,
81}
82
83struct Planned {
85 spec: InferenceSpec,
86 managed: Option<(ManagedPlan, String)>,
87 notes: Vec<String>,
88}
89
90pub struct InferRuntime {
92 pub(super) cfg: InferConfig,
93 id: String,
94 caps: RwLock<Vec<Capability>>,
95 pub(super) instances: Mutex<HashMap<String, Instance>>,
96}
97
98fn refuse(m: impl Into<String>) -> RuntimeError {
99 RuntimeError::AdmissionRefused(m.into())
100}
101
102fn model_err(what: &str, e: ModelError) -> RuntimeError {
103 refuse(format!("{what}: {e}"))
104}
105
106impl InferRuntime {
107 pub fn new(cfg: InferConfig) -> Self {
110 let id = match cfg.mode {
111 InferMode::Adopted => format!("{}.adopted", cfg.flavor.id()),
112 InferMode::Managed(_) => cfg.flavor.id().to_string(),
113 };
114 Self {
115 cfg,
116 id,
117 caps: RwLock::new(Vec::new()),
118 instances: Mutex::new(HashMap::new()),
119 }
120 }
121
122 pub(super) fn managed_cfg(&self) -> Option<&ManagedConfig> {
123 match &self.cfg.mode {
124 InferMode::Managed(m) => Some(m),
125 InferMode::Adopted => None,
126 }
127 }
128
129 fn flavor(&self) -> InferFlavor {
130 self.cfg.flavor
131 }
132
133 pub async fn probe_capabilities(&self) -> Vec<Capability> {
135 let port = self
136 .cfg
137 .probe_port
138 .unwrap_or_else(|| self.flavor().default_port());
139 let caps = capabilities::probe(&self.cfg, port).await;
140 if let Ok(mut g) = self.caps.write() {
141 *g = caps.clone();
142 }
143 caps
144 }
145
146 fn client_for(&self, spec: &InferenceSpec) -> Result<ServerClient, RuntimeError> {
147 ServerClient::new(spec.base_url()?, self.cfg.probe_timeout)
148 }
149
150 fn resolve(&self, m: &ManagedConfig, name: &str) -> Result<ResolvedModel, RuntimeError> {
151 m.models
152 .resolve(name)
153 .map_err(|e| model_err(&format!("model {name}"), e))
154 }
155
156 async fn plan(&self, w: &VerifiedWorkload) -> Result<Planned, RuntimeError> {
157 let spec = w.inference_spec(&self.id)?.clone();
158 spec.validate()?;
159 if spec.runtime != self.flavor() {
160 return Err(refuse(format!(
161 "{} cannot run a {} workload",
162 self.id,
163 spec.runtime.id()
164 )));
165 }
166 if let Some((lo, hi)) = self.cfg.allowed_ports {
167 let p = spec.port()?;
168 if !(lo..=hi).contains(&p) {
169 return Err(refuse(format!(
170 "port {p} is outside this adapter's allowed range {lo}-{hi}"
171 )));
172 }
173 }
174 let client = self.client_for(&spec)?;
175 let mut notes = vec![format!("endpoint {}", client.base())];
176 let Some(m) = self.managed_cfg() else {
177 let r = client.probe(spec.runtime).await;
179 if let super::probe::Health::Unreachable(why) = &r.health {
180 return Err(refuse(format!(
181 "no {} server answering at {}: {why}",
182 spec.runtime.id(),
183 client.base()
184 )));
185 }
186 notes.push(format!("adopting running server ({:?})", r.health));
187 notes.push(format!("{} model(s) listed", r.models.len()));
188 if let Some(v) = &r.version {
189 notes.push(format!("version {v}"));
190 }
191 return Ok(Planned {
192 spec,
193 managed: None,
194 notes,
195 });
196 };
197 let name = spec
198 .model
199 .clone()
200 .ok_or_else(|| refuse("managed mode needs a model"))?;
201 let model = self.resolve(m, &name)?;
202 check_format(spec.runtime, &model)?;
203 notes.push(format!(
204 "model {} ({}) verified",
205 model.name,
206 model.format.as_str()
207 ));
208 if spec.runtime == InferFlavor::Ollama {
209 let r = client.probe(spec.runtime).await;
210 if matches!(r.health, super::probe::Health::Unreachable(_)) {
211 return Err(refuse(format!(
212 "ollama is not answering at {}; the adapter drives it but does not start it",
213 client.base()
214 )));
215 }
216 let tag = model
217 .body
218 .source
219 .ollama_tag
220 .clone()
221 .unwrap_or_else(|| model.name.clone());
222 if !r.lists(&tag) {
223 return Err(refuse(format!(
224 "ollama has no model {tag}: pull it first (the adapter never downloads)"
225 )));
226 }
227 return Ok(Planned {
228 spec,
229 managed: Some((ManagedPlan::Ollama { tag }, model.name)),
230 notes,
231 });
232 }
233 let draft = match &spec.serve.draft_model {
234 Some(d) => Some(self.resolve(m, d)?),
235 None => None,
236 };
237 let prog: &PathBuf = m
238 .serve_program
239 .as_ref()
240 .ok_or_else(|| refuse("managed mode needs a serve program"))?;
241 if !capabilities_is_exec(prog) {
242 return Err(refuse(format!(
243 "serve program {} is not executable",
244 prog.display()
245 )));
246 }
247 let (ip, port) = (spec.bind_ip()?, spec.port()?);
248 if port_in_use(ip, port).await {
249 return Err(refuse(format!(
250 "something is already listening on {ip}:{port}; adopt it instead of managing over it"
251 )));
252 }
253 let probe_id = "plan";
254 let launch = build_launch(spec.runtime, &spec, &model, draft.as_ref(), m, probe_id)?;
255 notes.push(format!(
256 "launch {} with {} args",
257 launch.program.display(),
258 launch.args.len()
259 ));
260 Ok(Planned {
261 spec,
262 managed: Some((ManagedPlan::Process(launch), model.name)),
263 notes,
264 })
265 }
266}
267
268fn capabilities_is_exec(p: &std::path::Path) -> bool {
269 use std::os::unix::fs::PermissionsExt;
270 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
271}
272
273fn instance_id(spec: &InferenceSpec, mode: &str, node_id: &str) -> Result<String, RuntimeError> {
275 if !valid_token(node_id, 64) {
276 return Err(RuntimeError::InvalidConfig(
277 "node id must be a plain token".into(),
278 ));
279 }
280 let mut h = blake3::Hasher::new();
281 h.update(spec.role.as_bytes());
282 h.update(spec.runtime.id().as_bytes());
283 h.update(spec.model.as_deref().unwrap_or("").as_bytes());
284 h.update(spec.base_url()?.as_bytes());
285 h.update(mode.as_bytes());
286 Ok(format!(
287 "{}-{}-{}",
288 spec.role,
289 &h.finalize().to_hex()[..8],
290 node_id
291 ))
292}
293
294#[async_trait]
295impl WorkloadRuntime for InferRuntime {
296 fn id(&self) -> &str {
297 &self.id
298 }
299
300 fn provides(&self) -> Vec<Capability> {
301 self.caps.read().map(|c| c.clone()).unwrap_or_default()
302 }
303
304 async fn admit(&self, w: &VerifiedWorkload) -> Result<Admission, RuntimeError> {
305 let p = self.plan(w).await?;
306 Ok(Admission {
307 runtime: self.id.clone(),
308 arch: std::env::consts::ARCH.into(),
309 emulated: false,
310 notes: p.notes,
311 })
312 }
313
314 async fn load(
315 &self,
316 w: &VerifiedWorkload,
317 cfg: &WorkloadConfig,
318 ) -> Result<InstanceHandle, RuntimeError> {
319 if cfg.mode != crate::workload_runtime::types::RunMode::Listener || !cfg.args.is_empty() {
320 return Err(RuntimeError::InvalidConfig(
321 "inference instances run as listeners and take their arguments from the spec"
322 .into(),
323 ));
324 }
325 let mode = if self.managed_cfg().is_some() {
326 "managed"
327 } else {
328 "adopted"
329 };
330 let p = self.plan(w).await?;
331 let id = instance_id(&p.spec, mode, &cfg.node_id)?;
332 let client = self.client_for(&p.spec)?;
333 let mut managed = None;
334 if let Some((plan, model_name)) = p.managed {
335 let mut plan = plan;
336 if let (ManagedPlan::Process(l), Some(m)) = (&mut plan, self.managed_cfg()) {
337 l.dir = m.data_root.join(&id);
338 std::fs::create_dir_all(&l.dir).map_err(|e| {
339 RuntimeError::Backend(format!("create {}: {e}", l.dir.display()))
340 })?;
341 }
342 managed = Some(Managed {
343 plan,
344 model_name,
345 proc: None,
346 wanted: false,
347 restarts: 0,
348 exited_at: None,
349 started_at: None,
350 last: None,
351 exposed: None,
352 load: None,
353 we_loaded: Default::default(),
354 });
355 }
356 let mut g = self.instances.lock().await;
357 if g.contains_key(&id) {
358 return Err(RuntimeError::InvalidState(format!(
359 "{id} is already loaded"
360 )));
361 }
362 let (ip, port) = (p.spec.bind_ip()?, p.spec.port()?);
363 if g.values().any(|i| {
364 i.spec.bind_ip().ok() == Some(ip)
365 && i.spec.port().ok() == Some(port)
366 && (managed.is_some() || i.managed.is_some())
367 }) {
368 return Err(refuse(format!(
369 "{ip}:{port} is already used by another instance"
370 )));
371 }
372 g.insert(
373 id.clone(),
374 Instance {
375 spec: p.spec.clone(),
376 client,
377 managed,
378 },
379 );
380 Ok(InstanceHandle {
381 runtime: self.id.clone(),
382 instance_id: id,
383 workload_id: p.spec.role,
384 store_installed: false,
385 })
386 }
387
388 async fn start(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
389 self.start_instance(h).await
390 }
391
392 async fn stop(&self, h: &InstanceHandle, grace: Duration) -> Result<RunEvidence, RuntimeError> {
393 self.stop_instance(h, grace).await
394 }
395
396 async fn unload(&self, h: InstanceHandle) -> Result<(), RuntimeError> {
397 self.unload_instance(&h).await
398 }
399
400 async fn status(&self, h: &InstanceHandle) -> InstanceStatus {
401 self.status_of(h).await
402 }
403
404 fn control_mode(&self) -> ControlMode {
405 match self.cfg.mode {
406 InferMode::Adopted => ControlMode::Adopted,
407 InferMode::Managed(_) => ControlMode::Managed,
408 }
409 }
410
411 fn network_exposure(&self) -> NetworkPolicy {
416 match self.cfg.mode {
417 InferMode::Adopted => NetworkPolicy::Lan,
418 InferMode::Managed(_) => NetworkPolicy::None,
419 }
420 }
421
422 async fn adopt(&self, h: &InstanceHandle, w: &VerifiedWorkload) -> Result<(), RuntimeError> {
423 if self.managed_cfg().is_some() {
424 return Err(RuntimeError::Unsupported(format!(
425 "{} cannot re-adopt {}: a managed server's process is not recoverable",
426 self.id, h.instance_id
427 )));
428 }
429 let p = self.plan(w).await?;
430 let client = self.client_for(&p.spec)?;
431 self.instances
432 .lock()
433 .await
434 .entry(h.instance_id.clone())
435 .or_insert(Instance {
436 spec: p.spec,
437 client,
438 managed: None,
439 });
440 Ok(())
441 }
442
443 async fn console(&self, h: &InstanceHandle, _c: &str) -> Result<RunEvidence, RuntimeError> {
444 Err(RuntimeError::Unsupported(format!(
445 "{} has no console run ({})",
446 self.id, h.instance_id
447 )))
448 }
449}
450
451impl InferRuntime {
452 pub async fn health(&self, h: &InstanceHandle) -> Result<ServerReport, RuntimeError> {
454 let (client, flavor) = {
455 let g = self.instances.lock().await;
456 let i = g
457 .get(&h.instance_id)
458 .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
459 (i.client.clone(), i.spec.runtime)
460 };
461 Ok(client.probe(flavor).await)
462 }
463
464 pub async fn endpoint(&self, h: &InstanceHandle) -> Option<String> {
467 self.instances
468 .lock()
469 .await
470 .get(&h.instance_id)
471 .map(|i| i.client.base().to_string())
472 }
473
474 pub async fn served_model(&self, h: &InstanceHandle) -> Option<String> {
478 let g = self.instances.lock().await;
479 let i = g.get(&h.instance_id)?;
480 match &i.managed {
481 Some(Managed {
482 plan: ManagedPlan::Ollama { tag },
483 ..
484 }) => Some(tag.clone()),
485 Some(m) => Some(m.model_name.clone()),
486 None => i.spec.model.clone(),
487 }
488 }
489
490 pub async fn spec_of(&self, h: &InstanceHandle) -> Option<InferenceSpec> {
492 self.instances
493 .lock()
494 .await
495 .get(&h.instance_id)
496 .map(|i| i.spec.clone())
497 }
498
499 pub async fn deep_health(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
502 let (client, model, flavor) = {
503 let g = self.instances.lock().await;
504 let i = g
505 .get(&h.instance_id)
506 .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
507 let model = i
508 .managed
509 .as_ref()
510 .map(|m| m.model_name.clone())
511 .or_else(|| i.spec.model.clone());
512 (i.client.clone(), model, i.spec.runtime)
513 };
514 let client = ServerClient::new(client.base().to_string(), self.cfg.deep_timeout)?;
515 let report = client.probe(flavor).await;
516 let model = model
517 .or_else(|| report.models.first().cloned())
518 .ok_or_else(|| RuntimeError::Backend("no model to ask".into()))?;
519 client
520 .tiny_completion(&model)
521 .await
522 .map_err(|e| RuntimeError::Backend(format!("deep health: {e}")))
523 }
524}