~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
345 lines · 15.6 KB Raw History
//! Composition parameters and exports: JS javascript/src/compose/params.js, the same words.
//! `parameters: [{key, label?, type?, required?, description?, secret?, minimum?, maximum?, values?: {default?, enum?}}]`;
//! `{"$param": key}` / `{"$host": name}` in instance input; `exports: [{nsid, instance, entry}]`.

use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};

const TYPES: [&str; 6] = ["string", "integer", "number", "boolean", "object", "array"];
const KEY: &str = "/^[A-Za-z_][A-Za-z0-9_]{0,63}$/";

fn key_ok(s: &str) -> bool {
    (1..=64).contains(&s.len()) && s.bytes().enumerate().all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()))
}

fn json(v: &Value) -> String {
    serde_json::to_string(v).unwrap_or_default()
}

/// JS String() of a scalar-or-missing, as the messages print one.
fn shown(v: Option<&Value>) -> String {
    match v {
        None => "undefined".into(),
        Some(Value::String(s)) => s.clone(),
        Some(v) => json(v),
    }
}

fn holder(v: &Value) -> Option<(&str, &str)> {
    let m = v.as_object().filter(|m| m.len() == 1)?;
    let (k, v) = m.iter().next()?;
    (k == "$param" || k == "$host").then_some(())?;
    Some((k.as_str(), v.as_str()?))
}

fn integer(v: &Value) -> bool {
    v.as_f64().is_some_and(|f| f.is_finite() && f.fract() == 0.0)
}

fn typed(kind: &str, v: &Value) -> bool {
    match kind {
        "string" => v.is_string(),
        "integer" => integer(v),
        "number" => v.as_f64().is_some_and(f64::is_finite),
        "boolean" => v.is_boolean(),
        "array" => v.is_array(),
        _ => v.is_object(),
    }
}

/// JS checkInput: an instance's input against its module's config: every key one of its fields, of its type
/// (a $param's parameter of that type; a $host value is the host's); a required field given. A module
/// without config takes no input.
pub fn check_input(inst: &Value, module: &crate::run::Module, parameters: Option<&Value>) -> Result<(), String> {
    let name = inst["name"].as_str().unwrap_or_default();
    let of = inst["module"].as_str().unwrap_or_default();
    let fail = |m: String| Err(format!("composition: instance {name}: {m}"));
    let empty = Map::new();
    let input = match inst.get("input") {
        None => &empty,
        Some(v) => match v.as_object() {
            Some(m) => m,
            None => return fail("input must be an object".into()),
        },
    };
    let fields = module.config.as_deref().unwrap_or_default();
    let ptype = |key: &str| parameters.and_then(Value::as_array).into_iter().flatten().find(|p| p["key"] == key).map(|p| p["type"].as_str().unwrap_or("string").to_string());
    for (key, v) in input {
        let Some(field) = fields.iter().find(|f| f.key == *key) else {
            let known = if fields.is_empty() { String::new() } else { format!(" ({})", fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>().join(", ")) };
            return fail(format!("input {key} is not in {of}'s config{known}"));
        };
        match holder(v) {
            Some(("$param", p)) => {
                if let Some(t) = ptype(p).filter(|t| *t != field.kind) {
                    return fail(format!("input {key} is {}, but parameter {p} is {t}", field.kind));
                }
            }
            Some(_) => {}
            None if !typed(&field.kind, v) => return fail(format!("input {key} must be of type {}", field.kind)),
            None => {}
        }
    }
    if let Some(f) = fields.iter().find(|f| f.required && !input.contains_key(&f.key)) {
        return fail(format!("input {} is required by {of}'s config", f.key));
    }
    Ok(())
}

/// Why `v` is not a value of parameter `p`, or None.
fn wrong(p: &Value, v: &Value) -> Option<String> {
    let kind = p.get("type").and_then(Value::as_str).unwrap_or("string");
    if !typed(kind, v) {
        return Some(format!("must be of type {kind}"));
    }
    if let Some(choices) = p.pointer("/values/enum").and_then(Value::as_array)
        && !choices.iter().any(|c| json(c) == json(v))
    {
        return Some(format!("must be one of {}", json(&Value::Array(choices.clone()))));
    }
    if let Some(n) = v.as_f64() {
        if p.get("minimum").and_then(Value::as_f64).is_some_and(|m| n < m) {
            return Some(format!("must be at least {}", json(&p["minimum"])));
        }
        if p.get("maximum").and_then(Value::as_f64).is_some_and(|m| n > m) {
            return Some(format!("must be at most {}", json(&p["maximum"])));
        }
    }
    None
}

fn holders<'a>(value: &'a Value, f: &mut dyn FnMut(&'a str, &'a str) -> Result<(), String>) -> Result<(), String> {
    if let Some((k, name)) = holder(value) {
        return f(k, name);
    }
    match value {
        Value::Array(items) => items.iter().try_for_each(|v| holders(v, f)),
        Value::Object(m) => m.values().try_for_each(|v| holders(v, f)),
        _ => Ok(()),
    }
}

/// compose::validate's part for parameters, placeholders and exports.
pub fn validate(manifest: &Value) -> Result<(), String> {
    let fail = |m: String| Err(format!("composition: {m}"));
    let params = match manifest.get("parameters") {
        None => &[][..],
        Some(Value::Array(l)) => &l[..],
        Some(_) => return fail("parameters must be a list".into()),
    };
    let mut keys = HashSet::new();
    for p in params {
        let key = p.get("key").and_then(Value::as_str).filter(|k| key_ok(k));
        let Some(key) = key.filter(|k| keys.insert(k.to_string())) else {
            return fail(format!("parameter key {} must be unique and match {KEY}", shown(p.get("key"))));
        };
        let kind = p.get("type").and_then(Value::as_str).unwrap_or("string");
        if p.get("type").is_some_and(|t| !t.is_string()) || !TYPES.contains(&kind) {
            return fail(format!("parameter {key}: type {} is not one of {}", shown(p.get("type")), TYPES.join(", ")));
        }
        for k in ["minimum", "maximum"] {
            if p.get(k).is_some_and(|n| !integer(n)) {
                return fail(format!("parameter {key}: {k} must be an integer"));
            }
        }
        let values = match p.get("values") {
            None => Map::new(),
            Some(Value::Object(m)) => m.clone(),
            Some(_) => return fail(format!("parameter {key}: values must be an object")),
        };
        if values.get("enum").is_some_and(|e| !e.as_array().is_some_and(|l| !l.is_empty())) {
            return fail(format!("parameter {key}: values.enum must be a non-empty list"));
        }
        if p.get("secret") == Some(&Value::Bool(true)) && values.contains_key("default") {
            return fail(format!("parameter {key}: a secret has no default"));
        }
        let listed = values.get("enum").and_then(Value::as_array).cloned().unwrap_or_default();
        for v in listed.iter().chain(values.get("default")) {
            if let Some(why) = wrong(p, v) {
                return fail(format!("parameter {key}: {} {why}", json(v)));
            }
        }
    }
    let instances = manifest["instances"].as_array().cloned().unwrap_or_default();
    for inst in &instances {
        let name = shown(inst.get("name"));
        if let Some(input) = inst.get("input") {
            holders(input, &mut |k, v| match k {
                "$param" if !keys.contains(v) => Err(format!("composition: instance {name}: $param {v} is not a parameter")),
                "$host" if !key_ok(v) => Err(format!("composition: instance {name}: $host {v} must match {KEY}")),
                _ => Ok(()),
            })?;
        }
    }
    let exports = match manifest.get("exports") {
        None => &[][..],
        Some(Value::Array(l)) => &l[..],
        Some(_) => return fail("exports must be a list".into()),
    };
    let names: HashSet<String> = instances.iter().map(|i| shown(i.get("name"))).collect();
    let mut nsids = HashSet::new();
    for e in exports {
        let nsid = e.get("nsid").and_then(Value::as_str).filter(|n| crate::compose::nsid_ok(n) && nsids.insert(n.to_string()));
        let Some(nsid) = nsid else {
            return fail(format!("export {} must be a unique NSID", shown(e.get("nsid"))));
        };
        let at = e.get("instance").and_then(Value::as_str).is_some_and(|i| names.contains(i));
        if !at || !e.get("entry").and_then(Value::as_str).is_some_and(crate::wasm::entry_ok) {
            return fail(format!("export {nsid}: instance must be an instance and entry an export name"));
        }
    }
    Ok(())
}

/// What bind fills placeholders from: parameter values, secret parameters' values, and the host's own.
#[derive(Clone, Debug, Default)]
pub struct Bind {
    pub inputs: Map<String, Value>,
    pub secrets: Map<String, Value>,
    pub host: Map<String, Value>,
}

impl Bind {
    /// `{inputs?, secrets?, host?}`, as the CLI's --params takes it.
    pub fn from_json(v: &Value) -> Result<Bind, String> {
        let part = |k: &str| match v.get(k) {
            None => Ok(Map::new()),
            Some(Value::Object(m)) => Ok(m.clone()),
            Some(_) => Err(format!("params: {k} must be an object")),
        };
        if !v.is_object() || v.as_object().is_some_and(|m| m.keys().any(|k| !["inputs", "secrets", "host"].contains(&k.as_str()))) {
            return Err("params must be an object of inputs, secrets and host".into());
        }
        Ok(Bind { inputs: part("inputs")?, secrets: part("secrets")?, host: part("host")? })
    }
}

/// JS bindParameters: the manifest with its placeholders filled. A missing value takes its default; a
/// required one without fails; an optional one without is dropped (null in a list).
pub fn bind(manifest: &Value, given: &Bind) -> Result<Value, String> {
    let fail = |m: String| Err(format!("composition: {m}"));
    let declared: Vec<&Value> = manifest.get("parameters").and_then(Value::as_array).into_iter().flatten().collect();
    let by_key: HashMap<&str, &Value> = declared.iter().filter_map(|p| Some((p.get("key")?.as_str()?, *p))).collect();
    let secret = |p: &Value| p.get("secret") == Some(&Value::Bool(true));
    for k in given.inputs.keys() {
        match by_key.get(k.as_str()) {
            None => return fail(format!("parameter {k} is not declared")),
            Some(p) if secret(p) => return fail(format!("parameter {k} is a secret: give it in secrets, not inputs")),
            _ => {}
        }
    }
    for k in given.secrets.keys() {
        if !by_key.get(k.as_str()).is_some_and(|p| secret(p)) {
            return fail(format!("secret {k} is not a secret parameter"));
        }
    }
    let mut values = HashMap::new();
    for p in &declared {
        let key = shown(p.get("key"));
        let from = if secret(p) { &given.secrets } else { &given.inputs };
        let Some(v) = from.get(&key).or_else(|| p.pointer("/values/default")) else {
            if p.get("required") == Some(&Value::Bool(true)) {
                return fail(format!("parameter {key} is required"));
            }
            continue;
        };
        if let Some(why) = wrong(p, v) {
            return fail(format!("parameter {key} {why}"));
        }
        values.insert(key, v.clone());
    }
    // None: absent (dropped from an object, null in a list)
    fn swap(value: &Value, values: &HashMap<String, Value>, host: &Map<String, Value>) -> Result<Option<Value>, String> {
        if let Some((k, name)) = holder(value) {
            if k == "$host" {
                return host.get(name).cloned().map(Some).ok_or(format!("composition: host value {name} is not given by this host"));
            }
            return Ok(values.get(name).cloned());
        }
        Ok(Some(match value {
            Value::Array(items) => Value::Array(items.iter().map(|v| Ok(swap(v, values, host)?.unwrap_or(Value::Null))).collect::<Result<_, String>>()?),
            Value::Object(m) => {
                let mut out = Map::new();
                for (k, v) in m {
                    if let Some(v) = swap(v, values, host)? {
                        out.insert(k.clone(), v);
                    }
                }
                Value::Object(out)
            }
            other => other.clone(),
        }))
    }
    let mut out = manifest.clone();
    for inst in out.get_mut("instances").and_then(Value::as_array_mut).into_iter().flatten() {
        let Some(input) = inst.get("input").cloned() else { continue };
        match swap(&input, &values, &given.host)? {
            Some(v) => inst["input"] = v,
            None => {
                inst.as_object_mut().map(|m| m.remove("input"));
            }
        }
    }
    Ok(out)
}

/// run_compose's guard: a placeholder still in an instance's input fails the run.
pub fn check_bound(manifest: &Value) -> Result<(), String> {
    for inst in manifest["instances"].as_array().into_iter().flatten() {
        if let Some(input) = inst.get("input") {
            holders(input, &mut |k, v| Err(format!("composition: {} {v} is not bound", if k == "$param" { "parameter" } else { "host value" })))?;
        }
    }
    Ok(())
}

/// The export serving contract `nsid`: `{nsid, instance, entry}`.
pub fn export_of<'a>(manifest: &'a Value, nsid: &str) -> Option<&'a Value> {
    manifest.get("exports")?.as_array()?.iter().find(|e| e.get("nsid").and_then(Value::as_str) == Some(nsid))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn manifest() -> Value {
        json!({
            "instances": [{ "name": "vm", "module": "m", "input": { "cpus": { "$param": "cpus" }, "who": [{ "$param": "who" }], "key": { "$host": "sshKey" }, "token": { "$param": "token" } } }],
            "parameters": [
                { "key": "cpus", "type": "integer", "minimum": 1, "maximum": 4, "values": { "default": 2 } },
                { "key": "who", "values": { "enum": ["a", "b"] } },
                { "key": "token", "secret": true, "required": true }
            ],
            "exports": [{ "nsid": "computer.socialweb.machine.provision", "instance": "vm", "entry": "contract" }]
        })
    }

    #[test]
    fn binds_validates_and_exports() {
        let m = manifest();
        validate(&m).unwrap();
        let host = json!({ "sshKey": "ssh-ed25519 K" }).as_object().unwrap().clone();
        let secrets = json!({ "token": "t" }).as_object().unwrap().clone();
        let bound = bind(&m, &Bind { inputs: Map::new(), secrets: secrets.clone(), host: host.clone() }).unwrap();
        assert_eq!(bound["instances"][0]["input"], json!({ "cpus": 2, "who": [null], "key": "ssh-ed25519 K", "token": "t" }));
        check_bound(&bound).unwrap();
        assert_eq!(check_bound(&m).unwrap_err(), "composition: parameter cpus is not bound");
        let inputs = |v: Value| Bind { inputs: v.as_object().unwrap().clone(), secrets: secrets.clone(), host: host.clone() };
        for (given, err) in [
            (json!({ "cpus": 9 }), "parameter cpus must be at most 4"),
            (json!({ "cpus": "2" }), "parameter cpus must be of type integer"),
            (json!({ "who": "c" }), "parameter who must be one of [\"a\",\"b\"]"),
            (json!({ "token": "t" }), "parameter token is a secret: give it in secrets, not inputs"),
            (json!({ "nope": 1 }), "parameter nope is not declared"),
        ] {
            assert_eq!(bind(&m, &inputs(given)).unwrap_err(), format!("composition: {err}"));
        }
        assert_eq!(bind(&m, &Bind { host: host.clone(), ..Bind::default() }).unwrap_err(), "composition: parameter token is required");
        assert_eq!(bind(&m, &Bind { secrets, ..Bind::default() }).unwrap_err(), "composition: host value sshKey is not given by this host");
        assert_eq!(export_of(&m, "computer.socialweb.machine.provision").unwrap()["entry"], "contract");
        let mut bad = m.clone();
        bad["parameters"][0]["values"]["default"] = json!(7);
        assert_eq!(validate(&bad).unwrap_err(), "composition: parameter cpus: 7 must be at most 4");
    }
}