metalos/host_configs/evalctx/src/starlark/generator.rs (322 lines of code) (raw):

/* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ use std::collections::BTreeMap; use std::fmt::Display; use std::ops::Deref; use std::path::Path; use anyhow::Context; use starlark::environment::{GlobalsBuilder, Module}; use starlark::eval::Evaluator; use starlark::starlark_module; use starlark::values::dict::DictOf; use starlark::values::list::ListOf; use starlark::values::{OwnedFrozenValue, StarlarkValue, Value, ValueLike}; use crate::generator::{Dir, File, Generator, Output}; use crate::starlark::loader::{Loader, ModuleId}; use crate::{Error, HostIdentity, Result}; // Macro-away all the Starlark boilerplate for structs that are _only_ returned // from Starlark, and are not expected to be able to be read/used from the // generator Starlark code. macro_rules! output_only_struct { ($x:ident) => { // TODO(nga): this is normally done with `derive(AnyLifetime)`. // Thrift types should better be wrapped in local struct to make it possible. unsafe impl starlark::values::ProvidesStaticType for $x { type StaticType = $x; } starlark::starlark_simple_value!($x); impl<'v> starlark::values::StarlarkValue<'v> for $x { starlark::starlark_type!(stringify!($x)); } impl serde::Serialize for $x { fn serialize<S>(&self, _s: S) -> std::result::Result<S::Ok, S::Error> where S: serde::Serializer, { Err(serde::ser::Error::custom(format!( "{} isn't serializable", stringify!($x) ))) } } impl Display for $x { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:#?}", self) } } }; } type Username = String; type PWHash = String; output_only_struct!(Output); output_only_struct!(Dir); output_only_struct!(File); fn collect_list_of<'v, T>(lst: ListOf<'v, Value<'v>>) -> anyhow::Result<Vec<T>> where T: StarlarkValue<'v> + Clone, { lst.to_vec() .into_iter() .map(|v| { let owned: T = v .downcast_ref() .with_context(|| format!("{:?} is not a {}", v, std::any::type_name::<T>())) .map(|v: &T| v.clone())?; Ok(owned) }) .collect() } #[starlark_module] pub fn module(registry: &mut GlobalsBuilder) { // TODO: accept symbolic strings in 'mode' as well #[starlark(type("File"))] fn file(path: &str, contents: &str, mode: Option<i32>) -> anyhow::Result<File> { Ok(File { path: path.into(), contents: contents.into(), mode: mode.map_or(0o444, |i| i as u32), }) } #[starlark(type("Dir"))] fn dir(path: &str) -> anyhow::Result<Dir> { Ok(Dir { path: path.into() }) } #[starlark(type("Output"))] fn Output( files: Option<ListOf<Value>>, dirs: Option<ListOf<Value>>, pw_hashes: Option<DictOf<Value, Value>>, ) -> anyhow::Result<Output> { let files = files.map_or_else(|| Ok(vec![]), collect_list_of)?; let dirs = dirs.map_or_else(|| Ok(vec![]), collect_list_of)?; let pw_hashes: Option<BTreeMap<Username, PWHash>> = match pw_hashes { Some(hashes) => Some( hashes .collect_entries() .into_iter() .map(|(k, v)| { Ok(( k.unpack_str() .context(format!("provided key {:?} was not a string", k))? .to_string(), v.unpack_str() .context(format!("provided value {:?} was not a string", v))? .to_string(), )) }) .collect::<anyhow::Result<_>>() .context("Failed to convert PW hashes from starlark to BTreeMap")?, ), None => None, }; Ok(Output { files, dirs, pw_hashes, }) } // this must match the type name returned by the HostIdentity struct const HostIdentity: &str = "HostIdentity"; } pub struct StarlarkGenerator { id: ModuleId, starlark_func: OwnedFrozenValue, } impl StarlarkGenerator { /// Recursively load a directory of starlark generators. These files must /// end in '.star' and define a function 'generator' that accepts a single /// [metalos.HostIdentity](crate::HostIdentity) parameter. Starlark files in /// the directory are available to be `load()`ed by generators. pub fn load(path: impl AsRef<Path>) -> Result<Vec<Self>> { Loader::load(path)? .into_iter() .map(|(id, module)| { let starlark_func = module.get("generator").ok_or(Error::NotGenerator)?; Ok(Self { id, starlark_func }) }) .filter(|r| match r { Err(Error::NotGenerator) => false, _ => true, }) .collect() } pub fn id(&self) -> &ModuleId { &self.id } } impl Generator for StarlarkGenerator { fn name(&self) -> &str { self.id.as_str() } fn eval(&self, host: &HostIdentity) -> Result<Output> { let module = Module::new(); let mut evaluator = Evaluator::new(&module); let host_value = evaluator.heap().alloc(host.clone()); let output = evaluator .eval_function(self.starlark_func.value(), &[], &[("host", host_value)]) .map_err(Error::EvalGenerator)?; // clone the result off the heap so that the Evaluator and Module can be safely dropped Ok(Output::from_value(output) .context("expected 'generator' to return 'metalos.Output'") .map_err(Error::EvalGenerator)? .deref() .clone()) } } #[cfg(test)] mod tests { use super::*; use starlark::codemap::FileSpanRef; use starlark::environment::Module; use starlark::eval::Evaluator; use starlark::syntax::{AstModule, Dialect}; use std::cell::{RefCell, RefMut}; use std::collections::BTreeSet; use std::env; use std::ffi::OsStr; use std::path::Path; use std::rc::Rc; use tempfile::TempDir; use walkdir::WalkDir; fn eval_one_generator(source: &'static str) -> anyhow::Result<Output> { let tmp_dir = TempDir::new()?; std::fs::write(tmp_dir.path().join("test_generator.star"), source)?; let mut generators = StarlarkGenerator::load(tmp_dir.path())?; assert_eq!(1, generators.len()); let gen = generators.remove(0); let host = HostIdentity::example_host_for_tests(); let result = gen.eval(&host)?; Ok(result) } // The hostname.star generator is super simple, so use that to test the // generator runtime implementation. #[test] fn hostname_generator() -> anyhow::Result<()> { assert_eq!( eval_one_generator( r#" def generator(host: metalos.HostIdentity) -> metalos.Output.type: return metalos.Output( files=[ metalos.file(path="/etc/hostname", contents=host.hostname + "\n"), ] ) "# )?, Output { files: vec![File { path: "/etc/hostname".into(), contents: "host001.01.abc0.facebook.com\n".into(), mode: 0o444, }], dirs: vec![], pw_hashes: None, } ); Ok(()) } // We use "extended" version of the Starlark language which includes // a built-in function that generates JSON, among other things. We may // rely on the presence and behaviour of this function when generating // some configs #[test] fn generator_with_json_call() -> anyhow::Result<()> { assert_eq!( eval_one_generator( r#" def generator(host: metalos.HostIdentity) -> metalos.Output.type: return metalos.Output( files=[ metalos.file(path="/test.json", contents=json({"a":"b","c":None})), ] ) "# )?, Output { files: vec![File { path: "/test.json".into(), contents: r#"{"a":"b","c":null}"#.into(), mode: 0o444, }], dirs: vec![], pw_hashes: None, } ); Ok(()) } #[test] fn generator_with_dir() -> anyhow::Result<()> { assert_eq!( eval_one_generator( r#" def generator(host: metalos.HostIdentity) -> metalos.Output.type: return metalos.Output( dirs=[ metalos.dir(path="/dir"), ] ) "# )?, Output { files: vec![], dirs: vec![Dir { path: "/dir".into(), }], pw_hashes: None, } ); Ok(()) } fn eval_one_generator_coverage(file_path: &Path) -> anyhow::Result<()> { // we need to put the test in a temp dir because `Generator::load` understands // only directories. let tmp_dir = TempDir::new()?; let filename = file_path .file_name() .unwrap_or_else(|| OsStr::new("test.star")); std::fs::copy(file_path, tmp_dir.path().join(filename))?; // get total number of statements and the lines numebrs that are supposed to be executed, // they will be used to calculate coverage. let src_code = std::fs::read_to_string(file_path)?; let ast = AstModule::parse(&filename.to_string_lossy(), src_code, &Dialect::Extended).unwrap(); let total_num_statements = ast.stmt_locations().len(); assert_ne!(0, total_num_statements); let to_visit_lines: BTreeSet<u16> = ast .stmt_locations() .into_iter() .map(|line| line.resolve_span().begin_line as u16) .collect(); let visited_lines: Rc<RefCell<_>> = Rc::new(RefCell::new(BTreeSet::new())); let before_stmt = |span: FileSpanRef, _eval: &mut Evaluator<'_, '_>| { let mut set: RefMut<_> = visited_lines.borrow_mut(); set.insert(span.resolve_span().begin_line as u16); }; let module = Module::new(); let mut evaluator = Evaluator::new(&module); evaluator.before_stmt(&before_stmt); let globals = crate::starlark::globals(); evaluator.eval_module(ast, &globals)?; let host = HostIdentity::example_host_for_tests(); let host_value = evaluator.heap().alloc(host); match module.get("generator") { None => anyhow::bail!( "Starlark file {:?} does not have a generator function", file_path.file_name() ), Some(function) => { evaluator.eval_function(function, &[], &[("host", host_value)])?; assert_eq!( to_visit_lines, visited_lines.borrow().clone(), "Starlark file {:?} has branches that are not executed", file_path.file_name() ); } } Ok(()) } #[test] fn test_all_generators_coverage() -> anyhow::Result<()> { // find all *.star files and test them to make sure: // * they run successufully // * their coverage is 100% by using starlark::eval::Evaluator::before_stmt let test_file_dir = env::var("TEST_FILES_DIR")?; for entry in WalkDir::new(test_file_dir) .follow_links(true) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file()) .filter(|e| e.path().extension() == Some(OsStr::new("star"))) { eval_one_generator_coverage(entry.path())?; } Ok(()) } }