Skip to main content

superfluid_daemon/
versions.rs

1//! `superfluid runtime versions <id> [--json]`, and the INSTALLABLE column of `superfluid runtimes`.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use serde_json::{json, Value};
7use superfluid_adapter_kit::install::{version_key, Candidate, Host, Unlisted};
8
9use crate::installs::{self, Install};
10use crate::runtime_pick::RuntimeId;
11use crate::runtimes::Catalog;
12
13pub struct Listing {
14    pub id: RuntimeId,
15    pub host: String,
16    /// What the adapter lists, in its order (newest first).
17    pub candidates: Vec<Candidate>,
18    /// Which versions it does not list install without `--untested`; none when every one needs it.
19    pub unlisted: Option<Unlisted>,
20    pub installs: Vec<Install>,
21    pub default: Option<String>,
22    pub pinned: Option<String>,
23}
24
25/// What the adapter lists for this machine: the worker named by `SUPERFLUID_WORKER_<ID>` when one is, else the
26/// linked adapter in process, else the worker `runtime install` would use; a worker answers from its recipe alone.
27pub fn of(catalog: &Catalog, id: RuntimeId) -> Result<Listing, String> {
28    let named = std::env::var_os(crate::runtimes::worker_env(id.base())).map(PathBuf::from);
29    of_with(catalog, id, named.as_deref())
30}
31
32pub fn of_with(catalog: &Catalog, id: RuntimeId, named: Option<&Path>) -> Result<Listing, String> {
33    let id = id.base();
34    let (host, candidates, unlisted) = match (named, crate::linked::get(id)) {
35        (Some(worker), _) => asked(worker)?,
36        (None, Some(rt)) => {
37            let host = Host::detect();
38            (host.describe(), rt.versions(&host), rt.unlisted())
39        }
40        (None, None) => asked(&installs::installer(catalog, id)?)?,
41    };
42    Ok(Listing {
43        id,
44        host,
45        candidates,
46        unlisted,
47        installs: installs::list(catalog, id),
48        default: installs::default(catalog, id).map(|i| i.name),
49        pinned: installs::pinned(catalog, id),
50    })
51}
52
53fn asked(worker: &Path) -> Result<(String, Vec<Candidate>, Option<Unlisted>), String> {
54    let mut cmd = Command::new(worker);
55    crate::exec::without_secrets(&mut cmd).arg("versions");
56    let out = crate::exec::output_within(&mut cmd, crate::exec::ANSWER_WITHIN)
57        .map_err(|e| format!("{} could not start: {e}", worker.display()))?
58        .ok_or_else(|| format!("{} versions did not answer within {}s", worker.display(), crate::exec::ANSWER_WITHIN.as_secs()))?;
59    match out.status.code() {
60        Some(0) => {}
61        // The kit's usage exit: an adapter from before `versions` existed reads it as a flag it does not know.
62        Some(2) => return Err(format!("{} lists no versions ({}): an adapter from before the listing existed", worker.display(), out.status)),
63        _ => return Err(format!("{} versions failed ({}){}", worker.display(), out.status, crate::exec::stderr_tail(&out.stderr))),
64    }
65    let v: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("{} versions printed no listing: {e}", worker.display()))?;
66    let listed = v["versions"].as_array().ok_or_else(|| format!("{} versions printed no listing", worker.display()))?;
67    let candidates = listed.iter().map(Candidate::from_json).collect::<Result<Vec<_>, _>>()?;
68    // An adapter from before `unlisted` says nothing of the versions it leaves out: each needs --untested, as then.
69    Ok((v["describe"].as_str().unwrap_or_default().to_string(), candidates, Unlisted::from_json(&v["unlisted"])))
70}
71
72/// The versions that install here, newest first (`installable` in `superfluid runtimes --json`).
73pub fn here(listing: &Listing) -> Vec<&str> {
74    let listed = listing.candidates.iter().filter(|c| c.here.is_ok()).map(|c| c.version.as_str());
75    listing.newer().into_iter().map(|i| i.manifest.version.as_str()).chain(listed).collect()
76}
77
78/// The INSTALLABLE cell: the newest version that installs here, marked when an install of it exists.
79pub fn cell(listing: &Result<Listing, String>) -> String {
80    let Ok(l) = listing else { return "-".into() };
81    match here(l).first() {
82        None => "none here".into(),
83        Some(v) if l.installs.iter().any(|i| i.manifest.version == *v) => format!("{v} (installed)"),
84        Some(v) => v.to_string(),
85    }
86}
87
88/// A line of the listing: a listed version, or an installed one the adapter does not list.
89struct Row<'a> {
90    version: &'a str,
91    listed: Option<&'a Candidate>,
92    /// Not listed, but installed, by this install, as the adapter's rule for the versions it leaves out installs it.
93    newer: Option<&'a Install>,
94    installed: Vec<&'a Install>,
95}
96
97impl Listing {
98    /// Installed versions the adapter does not list that its rule for them installs, newest first, one install of each: with a
99    /// rule, those made under a rule that asked what this one asks (`tested` alone says only that the adapter that installed it
100    /// took it, which may have been a build for another engine), newer than every listed version that installs here, as `runtime
101    /// install` with no version takes the newest it finds. An older one is not counted, as it may be of a line this build no
102    /// longer drives; with no listed version that installs here, whether this host could install one cannot be told.
103    fn newer(&self) -> Vec<&Install> {
104        let Some(rule) = &self.unlisted else { return Vec::new() };
105        let Some(newest_listed) = self.candidates.iter().filter(|c| c.here.is_ok()).map(|c| version_key(&c.version)).max() else {
106            return Vec::new();
107        };
108        let mut newer: Vec<&Install> = self
109            .installs
110            .iter()
111            .filter(|i| i.manifest.requires.as_ref() == Some(&rule.requires))
112            .filter(|i| !self.candidates.iter().any(|c| c.version == i.manifest.version))
113            .filter(|i| version_key(&i.manifest.version) > newest_listed)
114            .collect();
115        newer.sort_by_key(|i| std::cmp::Reverse(version_key(&i.manifest.version)));
116        newer.dedup_by(|a, b| a.manifest.version == b.manifest.version);
117        newer
118    }
119
120    fn rows(&self) -> Vec<Row<'_>> {
121        let of_version = |v: &str| self.installs.iter().filter(|i| i.manifest.version == v).collect::<Vec<_>>();
122        let mut rows: Vec<Row> = self
123            .candidates
124            .iter()
125            .map(|c| Row { version: &c.version, listed: Some(c), newer: None, installed: of_version(&c.version) })
126            .collect();
127        // Newest first: each goes ahead of the listed versions older than it (a listed one this build does not drive may be newer).
128        for i in self.newer() {
129            let v = i.manifest.version.as_str();
130            let at = rows.iter().position(|r| version_key(r.version) < version_key(v)).unwrap_or(rows.len());
131            rows.insert(at, Row { version: v, listed: None, newer: Some(i), installed: of_version(v) });
132        }
133        for i in &self.installs {
134            if !rows.iter().any(|r| r.version == i.manifest.version) {
135                rows.push(Row { version: &i.manifest.version, listed: None, newer: None, installed: of_version(&i.manifest.version) });
136            }
137        }
138        rows
139    }
140
141    fn mark(&self, name: &str) -> String {
142        if self.pinned.as_deref() == Some(name) {
143            format!("{name} (pinned)")
144        } else if self.default.as_deref() == Some(name) {
145            format!("{name} (default)")
146        } else {
147            name.to_string()
148        }
149    }
150
151    pub fn text(&self) -> String {
152        let mut out = format!("{}: the versions this adapter installs on its own, newest first; this machine is {}\n", self.id, self.host);
153        let rows = self.rows();
154        if rows.is_empty() {
155            out.push_str("  none: the adapter lists no version, and nothing is installed\n");
156        } else {
157            let mut table = vec![["VERSION", "INSTALLS HERE AS", "INSTALLED"].map(String::from).to_vec()];
158            for r in &rows {
159                let here = match (r.listed.map(|c| (c, &c.here)), r.newer) {
160                    (Some((c, Ok(p))), _) => match c.size() {
161                        0 => p.install.clone(),
162                        size => format!("{} ({:.1} MB)", p.install, size as f64 / 1e6),
163                    },
164                    (Some((_, Err(why))), _) => format!("no: {why}"),
165                    (None, Some(i)) => format!("{} (newer than the versions listed)", i.manifest.install),
166                    (None, None) => "(not a version this adapter lists)".to_string(),
167                };
168                let installed = match r.installed.as_slice() {
169                    [] => "-".to_string(),
170                    some => some.iter().map(|i| self.mark(&i.name)).collect::<Vec<_>>().join(", "),
171                };
172                table.push(vec![r.version.to_string(), here, installed]);
173            }
174            out.push_str(&crate::runtimes_report::columns(&table));
175        }
176        out.push_str(&format!(
177            "`superfluid runtime install {} --version <v>` installs one; {}\n",
178            self.id,
179            self.unlisted.as_ref().map_or("a version not listed installs with --untested", |u| u.describe.as_str())
180        ));
181        out
182    }
183
184    pub fn json(&self) -> Value {
185        let versions: Vec<Value> = self
186            .rows()
187            .iter()
188            .map(|r| {
189                let mut v = json!({
190                    "version": r.version,
191                    "listed": r.listed.is_some(),
192                    "installed": r.installed.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
193                });
194                match (r.listed.map(|c| (c, &c.here)), r.newer) {
195                    (Some((c, Ok(p))), _) => {
196                        v["installable"] = true.into();
197                        v["install"] = json!(p.install);
198                        v["backend"] = json!(p.backend);
199                        v["size"] = json!(c.size());
200                    }
201                    (Some((_, Err(why))), _) => v["installable"] = json!(why),
202                    (None, Some(i)) => {
203                        v["installable"] = true.into();
204                        v["install"] = json!(i.manifest.install);
205                        v["backend"] = json!(i.manifest.backend);
206                    }
207                    (None, None) => {}
208                }
209                v
210            })
211            .collect();
212        json!({
213            "id": self.id.name(), "host": self.host, "default": self.default, "pinned": self.pinned, "versions": versions,
214            "unlisted": self.unlisted.as_ref().map(|u| &u.describe),
215        })
216    }
217}
218
219#[cfg(test)]
220pub(crate) mod tests {
221    use super::*;
222    use std::os::unix::fs::PermissionsExt;
223    use std::path::PathBuf;
224
225    pub(crate) fn scratch(tag: &str) -> PathBuf {
226        let d = std::env::temp_dir().join(format!("superfluid-versions-{tag}-{}", std::process::id()));
227        let _ = std::fs::remove_dir_all(&d);
228        std::fs::create_dir_all(&d).unwrap();
229        d
230    }
231
232    /// A worker that answers `versions` with `listing` and anything else with `report`.
233    pub(crate) fn fake_worker_listing(dir: &Path, name: &str, report: &str, listing: &str) -> PathBuf {
234        std::fs::create_dir_all(dir).unwrap();
235        let p = dir.join(name);
236        std::fs::write(&p, format!("#!/bin/sh\nif [ \"$1\" = versions ]; then cat <<'EOF'\n{listing}\nEOF\nexit 0; fi\ncat <<'EOF'\n{report}\nEOF\nexit 0\n"))
237            .unwrap();
238        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
239        p
240    }
241
242    /// An install as `superfluid runtime install` leaves it, for `installs::list`.
243    pub(crate) fn manifest(home: &Path, id: &str, name: &str, version: &str, backend: &str, tested: bool) -> PathBuf {
244        let dir = home.join("runtimes").join(id).join(name);
245        std::fs::create_dir_all(dir.join("bin")).unwrap();
246        let m = json!({"id": id, "install": name, "version": version, "backend": backend, "worker": format!("bin/superfluid-worker-{id}"), "tested": tested});
247        std::fs::write(dir.join("runtime.json"), format!("{m:#}\n")).unwrap();
248        dir
249    }
250
251    pub(crate) const LISTING: &str = r#"{"describe":"macOS arm64","versions":[
252        {"version":"t2","error":"toyrt t2 publishes no build for macos-arm64"},
253        {"version":"t1","plan":{"id":"toyrt","version":"t1","backend":"metal","install":"t1-metal","assets":[{"name":"a.tgz","url":"https://x/a.tgz","sha256":"ab","size":2500000}],"tested":true,"why":"w","extra":{"wheels_size":500000}}}]}"#;
254
255    #[test]
256    fn a_listing_says_which_versions_install_here_and_which_are_installed() {
257        let root = scratch("listing");
258        let (beside, libexec, home) = (root.join("bin"), root.join("libexec").join("superfluid"), root.join("home"));
259        std::fs::create_dir_all(&beside).unwrap();
260        std::fs::create_dir_all(&libexec).unwrap();
261        fake_worker_listing(&libexec, "superfluid-worker-toyrt", "{}", LISTING);
262        manifest(&home, "toyrt", "t1-metal", "t1", "metal", true);
263        manifest(&home, "toyrt", "t9-cpu", "t9", "cpu", false);
264        let cat = Catalog::new(home.clone(), Some(beside.clone()), beside.join("superfluid-workerd"));
265        let id = RuntimeId::new("toyrt");
266
267        let l = of(&cat, id).unwrap();
268        assert_eq!((l.host.as_str(), l.default.as_deref(), l.pinned.as_deref()), ("macOS arm64", Some("t1-metal"), None));
269        let text = l.text();
270        // Columns are padded to the widest cell; the words and their order are what is checked.
271        let squeezed: Vec<String> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).collect();
272        assert_eq!(squeezed[0], "toyrt: the versions this adapter installs on its own, newest first; this machine is macOS arm64");
273        assert_eq!(squeezed[1], "VERSION INSTALLS HERE AS INSTALLED");
274        assert_eq!(squeezed[2], "t2 no: toyrt t2 publishes no build for macos-arm64 -");
275        assert_eq!(squeezed[3], "t1 t1-metal (3.0 MB) t1-metal (default)");
276        assert_eq!(squeezed[4], "t9 (not a version this adapter lists) t9-cpu");
277        assert_eq!(squeezed[5], "`superfluid runtime install toyrt --version <v>` installs one; a version not listed installs with --untested");
278        assert_eq!(squeezed.len(), 6);
279        let lines: Vec<&str> = text.lines().collect();
280        assert!(lines[1].starts_with("VERSION  INSTALLS HERE AS") && lines[2].starts_with("t2       no: "), "aligned: {text}");
281        assert_eq!(lines[1].find("INSTALLED"), lines[3].find("t1-metal (default)"), "aligned: {text}");
282        let j = l.json();
283        assert_eq!(j["id"], "toyrt");
284        assert_eq!(j["versions"][0]["installable"], "toyrt t2 publishes no build for macos-arm64");
285        assert_eq!(j["versions"][0]["installed"], json!([]));
286        assert_eq!((j["versions"][1]["installable"].as_bool(), j["versions"][1]["install"].as_str()), (Some(true), Some("t1-metal")));
287        assert_eq!((j["versions"][1]["size"].as_u64(), j["versions"][1]["installed"][0].as_str()), (Some(3_000_000), Some("t1-metal")));
288        assert_eq!((j["versions"][2]["listed"].as_bool(), j["versions"][2]["installable"].is_null()), (Some(false), true));
289        assert_eq!(here(&l), ["t1"]);
290        assert_eq!(cell(&Ok(l)), "t1 (installed)");
291
292        std::fs::write(home.join("runtimes").join("toyrt").join("pinned"), "t9-cpu\n").unwrap();
293        let l = of(&cat, id).unwrap();
294        let text = l.text();
295        let squeezed: Vec<String> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).collect();
296        assert_eq!(squeezed[3], "t1 t1-metal (3.0 MB) t1-metal", "{text}");
297        assert_eq!(squeezed[4], "t9 (not a version this adapter lists) t9-cpu (pinned)", "{text}");
298        assert_eq!(l.json()["pinned"], "t9-cpu");
299
300        let bare = Listing { installs: vec![], default: None, pinned: None, candidates: vec![Candidate { version: "t2".into(), here: Err("no".into()) }], ..l };
301        assert_eq!(cell(&Ok(bare)), "none here");
302
303        crate::runtimes::tests::fake_worker(&libexec, "superfluid-worker-oldrt", "{}", 2);
304        let Err(e) = of(&cat, RuntimeId::new("oldrt")) else { panic!("an old worker listed versions") };
305        assert!(e.contains("lists no versions"), "{e}");
306        assert_eq!(cell(&Err(e)), "-");
307        let fails = libexec.join("superfluid-worker-failrt");
308        std::fs::write(&fails, "#!/bin/sh\necho 'superfluid-worker-failrt: recipe.json is not JSON' >&2\nexit 1\n").unwrap();
309        std::fs::set_permissions(&fails, std::fs::Permissions::from_mode(0o755)).unwrap();
310        let Err(e) = of(&cat, RuntimeId::new("failrt")) else { panic!("a failing worker listed versions") };
311        assert!(e.contains("versions failed") && e.ends_with(": superfluid-worker-failrt: recipe.json is not JSON"), "{e}");
312        assert!(!e.contains("before the listing existed"), "a new adapter that fails is not an old one: {e}");
313        let named = fake_worker_listing(&root.join("elsewhere"), "superfluid-worker-named", "{}", r#"{"describe":"here","versions":[{"version":"n1","error":"not here"}]}"#);
314        for id in crate::linked::ids().into_iter().chain([RuntimeId::new("toyrt")]) {
315            let l = of_with(&cat, id, Some(&named)).unwrap();
316            assert_eq!((l.host.as_str(), l.candidates.len(), l.candidates[0].version.as_str()), ("here", 1, "n1"), "{}", id.name());
317        }
318        let _ = std::fs::remove_dir_all(&root);
319    }
320
321    #[test]
322    fn a_newer_install_the_adapters_rule_installs_is_listed_as_installable() {
323        let root = scratch("unlisted");
324        let (beside, libexec, home) = (root.join("bin"), root.join("libexec").join("superfluid"), root.join("home"));
325        std::fs::create_dir_all(&beside).unwrap();
326        let rule = "with no version it installs the newest toy release, listed or not; another installs with --untested";
327        let unlisted = format!(r#""unlisted":{{"describe":"{rule}","requires":"toy ABI 1"}},"versions":["#);
328        fake_worker_listing(&libexec, "superfluid-worker-rulert", "{}", &LISTING.replacen(r#""versions":["#, &unlisted, 1));
329        // t1.5 as `runtime install` with no version leaves a release newer than the pins, under a rule that asked what this one
330        // asks. t1.8 went in under a build for another toy ABI, and t1.6 before manifests kept what the rule asked: `tested` only
331        // says the adapter of the time took them. t0 is older than the pins, and t9 went in with --untested.
332        for (name, version, backend, tested, requires) in [
333            ("t1.5-metal", "t1.5", "metal", true, Some("toy ABI 1")),
334            ("t1.8-metal", "t1.8", "metal", true, Some("toy ABI 2")),
335            ("t1.6-metal", "t1.6", "metal", true, None),
336            ("t0-metal", "t0", "metal", true, Some("toy ABI 1")),
337            ("t9-cpu", "t9", "cpu", false, None),
338        ] {
339            let path = manifest(&home, "rulert", name, version, backend, tested).join("runtime.json");
340            let mut m: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
341            m["requires"] = json!(requires);
342            std::fs::write(&path, m.to_string()).unwrap();
343        }
344        let cat = Catalog::new(home.clone(), Some(beside.clone()), beside.join("superfluid-workerd"));
345        let l = of(&cat, RuntimeId::new("rulert")).unwrap();
346        assert_eq!(l.unlisted, Some(Unlisted { describe: rule.into(), requires: "toy ABI 1".into() }));
347        let text = l.text();
348        let squeezed: Vec<String> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).collect();
349        assert_eq!(squeezed[2], "t2 no: toyrt t2 publishes no build for macos-arm64 -", "{text}");
350        assert_eq!(squeezed[3], "t1.5 t1.5-metal (newer than the versions listed) t1.5-metal", "newest first: {text}");
351        assert_eq!(squeezed[4], "t1 t1-metal (3.0 MB) -", "{text}");
352        for (line, install) in squeezed[5..9].iter().zip(["t1.8-metal", "t1.6-metal", "t0-metal", "t9-cpu"]) {
353            let version = install.split('-').next().unwrap();
354            assert!(line.starts_with(&format!("{version} (not a version this adapter lists) {install}")), "{text}");
355        }
356        assert_eq!(squeezed[9], format!("`superfluid runtime install rulert --version <v>` installs one; {rule}"), "{text}");
357        let j = l.json();
358        assert_eq!(j["unlisted"], rule);
359        assert_eq!(j["versions"][1], json!({"version": "t1.5", "listed": false, "installable": true, "install": "t1.5-metal", "backend": "metal", "installed": ["t1.5-metal"]}));
360        assert!((3..7).all(|n| j["versions"][n]["installable"].is_null()), "{j}");
361        assert_eq!(here(&l), ["t1.5", "t1"]);
362        assert_eq!(cell(&Ok(l)), "t1.5 (installed)");
363
364        // With no listed version that installs here, whether this host could install one cannot be told.
365        let l = of(&cat, RuntimeId::new("rulert")).unwrap();
366        let candidates = l.candidates.iter().filter(|c| c.here.is_err()).cloned().collect();
367        let l = Listing { candidates, ..l };
368        assert!(here(&l).is_empty());
369        assert_eq!(cell(&Ok(l)), "none here");
370        // Without a rule an unlisted version needs --untested, whatever its install says.
371        let l = Listing { unlisted: None, ..of(&cat, RuntimeId::new("rulert")).unwrap() };
372        assert_eq!(here(&l), ["t1"]);
373        assert_eq!(cell(&Ok(l)), "t1");
374        let _ = std::fs::remove_dir_all(&root);
375    }
376}