use clawft_types::placement::{
AttrValue, Capability, CapabilityId, NodeFacts, ProbeNote, Provenance,
};
use super::host::ProbeHost;
use super::{accel, linux, macos, runtimes};
pub const DEFAULT_FACTS_TTL_SECS: u64 = 600;
pub const MAX_IMAGE_REF_LEN: usize = 255;
pub fn valid_image_ref(s: &str) -> bool {
let mut chars = s.chars();
s.len() <= MAX_IMAGE_REF_LEN
&& matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | ':' | '@' | '-'))
}
#[derive(Debug)]
pub struct EmulationCache {
ttl: std::time::Duration,
slot: std::sync::Mutex<Option<(std::time::Instant, String, String)>>,
}
impl EmulationCache {
pub fn new(ttl: std::time::Duration) -> Self {
Self {
ttl,
slot: std::sync::Mutex::new(None),
}
}
pub(crate) fn get(&self, image: &str) -> Option<String> {
let g = self.slot.lock().unwrap_or_else(|p| p.into_inner());
g.as_ref()
.filter(|(at, img, _)| img == image && at.elapsed() < self.ttl)
.map(|(_, _, l)| l.clone())
}
pub(crate) fn put(&self, image: &str, listing: &str) {
*self.slot.lock().unwrap_or_else(|p| p.into_inner()) =
Some((std::time::Instant::now(), image.to_string(), listing.to_string()));
}
}
#[derive(Debug, Clone)]
pub struct ProbeConfig {
pub docker_probe_image: Option<String>,
pub declared_feeds: Vec<Capability>,
pub measured: Vec<Capability>,
pub models: Vec<Capability>,
pub emulation_cache: Option<std::sync::Arc<EmulationCache>>,
}
impl Default for ProbeConfig {
fn default() -> Self {
Self {
docker_probe_image: Some("alpine:3.20".into()),
declared_feeds: Vec::new(),
measured: Vec::new(),
models: Vec::new(),
emulation_cache: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Collected {
pub caps: Vec<Capability>,
pub notes: Vec<ProbeNote>,
}
impl Collected {
pub fn push(&mut self, cap: Capability) {
self.caps.push(cap);
}
pub fn note(&mut self, probe: &str, note: impl Into<String>) {
self.notes.push(ProbeNote::new(probe, note));
}
pub fn has(&self, id: &str) -> bool {
self.caps.iter().any(|c| c.id.as_str() == id)
}
}
pub(crate) fn public_mount(mount: &str) -> Option<&str> {
let first = mount.trim_start_matches('/').split('/').next().unwrap_or("");
const PRIVATE: &[&str] = &["Users", "home", "media", "run", "mnt", "Volumes", "root", "private"];
(!PRIVATE.contains(&first)).then_some(mount)
}
pub(crate) fn note_external_withheld(c: &mut Collected, count: usize) {
if count > 0 {
c.note(
"store.tier.external",
format!("{count} external volume(s) mounted; names and mount points are not advertised"),
);
}
}
pub fn cap(id: &str, provenance: Provenance) -> Option<Capability> {
CapabilityId::new(id)
.ok()
.map(|id| Capability::new(id, provenance))
}
pub fn str_list<S: AsRef<str>>(items: &[S]) -> AttrValue {
AttrValue::List(
items
.iter()
.map(|s| AttrValue::Str(s.as_ref().into()))
.collect(),
)
}
pub fn bytes_attr(n: u64) -> AttrValue {
AttrValue::Int(i64::try_from(n).unwrap_or(i64::MAX))
}
pub fn normalize_arch(raw: &str) -> String {
match raw.trim().to_ascii_lowercase().as_str() {
"aarch64" | "arm64" | "arm64/v8" => "aarch64".into(),
"arm" | "armv7" | "armv7l" | "armhf" | "arm/v7" => "armv7".into(),
"armv6" | "armv6l" | "arm/v6" => "armv6".into(),
"x86_64" | "amd64" | "x64" | "amd64/v2" | "amd64/v3" => "x86_64".into(),
"i386" | "i686" | "x86" | "386" => "i386".into(),
"mips64el" | "mips64le" => "mips64le".into(),
other => other
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect(),
}
}
pub fn parse_df(out: &str) -> Option<(u64, String)> {
let line = out.lines().nth(1)?;
let toks: Vec<&str> = line.split_whitespace().collect();
let pct = toks.iter().rposition(|t| t.ends_with('%'))?;
let avail_kb: u64 = toks.get(pct.checked_sub(1)?)?.parse().ok()?;
let mount = toks.get(pct + 1..)?.join(" ");
if mount.is_empty() {
return None;
}
Some((avail_kb.saturating_mul(1024), mount))
}
pub fn probe_capabilities(host: &dyn ProbeHost, cfg: &ProbeConfig) -> Collected {
let mut c = Collected::default();
let arch = normalize_arch(&host.arch());
match host.os().as_str() {
"macos" => macos::probe(host, &arch, &mut c),
"linux" => linux::probe(host, &arch, &mut c),
other => {
if let Some(cp) = cap(&format!("cpu.arch.{arch}"), Provenance::Probed) {
c.push(cp);
}
c.note(
"os",
format!("no probes for os {other:?}; only the arch is advertised"),
);
}
}
runtimes::probe(host, &arch, cfg, &mut c);
accel::probe(host, &mut c);
derive_formats(&mut c);
for feed in &cfg.declared_feeds {
if feed.id.family() == "feed" {
let mut f = feed.clone();
f.provenance = Provenance::Claimed;
c.push(f);
}
}
if !cfg.declared_feeds.is_empty() {
c.note(
"feed",
"sensor feeds are operator-declared (claimed), not probed",
);
}
let held = c.caps.len();
c.caps.extend(
cfg.models
.iter()
.filter(|m| {
m.id.as_str() == "model.present" || m.id.as_str() == "store.tier.external"
})
.cloned()
.map(|mut m| {
m.provenance = Provenance::Probed;
m
}),
);
if c.caps.len() > held {
c.note(
"model",
"model.present is from the local model registry (existence and size checks); paths and drive names are not advertised",
);
}
let before = c.caps.len();
c.caps.extend(
cfg.measured
.iter()
.filter(|m| m.id.family() == "perf" && m.provenance == Provenance::Measured)
.cloned(),
);
if c.caps.len() > before {
c.note(
"perf",
format!(
"{} measured perf.* values from the conformance harness",
c.caps.len() - before
),
);
}
c
}
fn derive_formats(c: &mut Collected) {
let mut by_format: std::collections::BTreeMap<String, (Vec<String>, Provenance)> =
Default::default();
for cp in &c.caps {
if !matches!(cp.id.family(), "runtime" | "accel") {
continue;
}
if let Some(AttrValue::List(fmts)) = cp.attrs.get("formats") {
for f in fmts {
if let AttrValue::Str(f) = f {
let e = by_format
.entry(f.clone())
.or_insert_with(|| (Vec::new(), cp.provenance));
e.0.push(cp.id.to_string());
e.1 = e.1.max(cp.provenance);
}
}
}
}
for (fmt, (via, prov)) in by_format {
if let Some(fc) = cap(&format!("format.{fmt}"), prov) {
c.push(fc.with_attr("via", str_list(&via)));
}
}
}
pub fn build_facts(
node_id: &str,
issued_at: u64,
ttl_secs: u64,
seq: u64,
mut collected: Collected,
) -> NodeFacts {
use clawft_types::placement::node_facts::{MAX_FACTS_CAPABILITIES, MAX_PROBE_NOTES};
let mut facts = NodeFacts::new(node_id, issued_at, ttl_secs, seq);
collected
.caps
.retain(|c| c.validate().is_ok() && c.id.family() != "trust");
if collected.caps.len() > MAX_FACTS_CAPABILITIES {
collected.caps.truncate(MAX_FACTS_CAPABILITIES);
collected.note("facts", "capability list truncated to the NodeFacts bound");
}
collected.notes.truncate(MAX_PROBE_NOTES);
facts.capabilities = collected.caps;
facts.notes = collected.notes;
facts
}
pub fn refresh_live(host: &dyn ProbeHost, base: &NodeFacts, now: u64) -> NodeFacts {
let mut f = base.clone();
f.issued_at = now;
let free = match host.os().as_str() {
"macos" => macos::live_free(host),
"linux" => linux::live_free(host),
_ => None,
};
if let Some(free) = free {
for c in f
.capabilities
.iter_mut()
.filter(|c| matches!(c.id.as_str(), "mem.unified" | "mem.system"))
{
let total = match c.attrs.get("total") {
Some(AttrValue::Int(t)) if *t >= 0 => *t as u64,
_ => u64::MAX,
};
c.attrs.insert("free".into(), bytes_attr(free.min(total)));
}
}
f
}