//! 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::>().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 { 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 = 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, pub secrets: Map, pub host: Map, } impl Bind { /// `{inputs?, secrets?, host?}`, as the CLI's --params takes it. pub fn from_json(v: &Value) -> Result { 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 { 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, host: &Map) -> Result, 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::>()?), 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"); } }