nerpa-config 0.3.0

Evaluates a Starlark program into a Nerpa resource graph
//! Evaluating a configuration into a resource graph.
//!
//! Expands: 0005
//!
//! Configuration is a Starlark program (`0005`). It is
//! evaluated with no I/O, no clock and no network, so the same source always
//! produces the same graph — which is what makes a plan worth signing.
//!
//! # How the pieces fit
//!
//! ```text
//!   main.star ──── evaluate() ──── Graph
//!                      │
//!                      ├── resource(address, {...}) ─── declares a node
//!                      │        │
//!                      │        └── db.ip ─── Handle: exists later, not now
//!                      │
//!                      └── node(name, provided_by = db) ─── the inventory
//! ```
//!
//! # Correctness
//!
//! The property this crate exists to hold is that **an unresolved value never
//! determines which resources exist**. It is held in two places, because the
//! toolchain allows only one of them to be precise: comparison, ordering,
//! indexing and iteration are refused where they happen, with a file and line;
//! truthiness is recorded and the whole evaluation is refused afterwards, and
//! equality is refused before evaluation begins because Starlark answers it
//! without consulting the value at all. Decision 0018 says why each is
//! different.

// The only place in the workspace where either of these is lifted, and both are
// forced by Starlark's derive macros rather than by anything written here:
// `ProvidesStaticType` generates an `unsafe impl`, and the expansions spell out
// fully qualified paths that `unused_qualifications` objects to. No hand-written
// line in this crate uses `unsafe`.
#![allow(
    unsafe_code,
    unused_qualifications,
    reason = "generated by Starlark's derive macros, not written here"
)]

pub mod fleet;
mod globals;
mod handle;
mod refuse;
pub mod template;

use std::cell::RefCell;

use nerpa_core::{Address, Graph, Resource, Target};
use starlark::environment::{GlobalsBuilder, Module};
use starlark::eval::Evaluator;
use starlark::syntax::{AstModule, Dialect};

/// Why a configuration could not be turned into a graph.
///
/// The variants carry the message a person will read rather than a code they
/// would have to look up, which makes the type large. That is the same trade
/// `GraphError` makes, on a path that runs once per plan.
#[derive(Debug, thiserror::Error)]
#[allow(
    clippy::large_enum_variant,
    reason = "diagnostics beat a lint on a cold path"
)]
#[non_exhaustive]
pub enum ConfigError {
    /// The program did not parse, or failed while running.
    #[error("{0}")]
    Starlark(String),
    /// A declaration named something the resource model refuses.
    #[error("{0}")]
    Declaration(String),
    /// An attribute that will not exist until apply was compared for equality or
    /// containment, where the language would answer silently rather than fail.
    #[error("{0}")]
    ComparedUnresolved(String),
    /// A value that will not exist until apply was used as a condition.
    #[error(
        "a value that will not exist until apply was used as a condition: {0}. \
         Which resources exist cannot depend on something that has not happened yet"
    )]
    BranchedOnUnresolved(String),
    /// The configuration asked for a different nerpa build than this one.
    #[error("this configuration requires nerpa {0}, and this build is {1}")]
    VersionMismatch(String, String),
    /// The declarations did not form a usable graph.
    #[error(transparent)]
    Graph(#[from] nerpa_core::GraphError),
}

/// What a run of the configuration declared, before it becomes a graph.
#[derive(Debug, starlark::any::ProvidesStaticType)]
pub(crate) struct Declarations {
    fleet: crate::fleet::Fleet,
    renderer: crate::template::Renderer,
    resources: RefCell<Vec<Resource>>,
    nodes: RefCell<Vec<(Target, Option<Address>)>>,
    moves: RefCell<Vec<(Address, Address)>>,
    problems: RefCell<Vec<String>>,
    required_version: RefCell<Option<String>>,
    schema: nerpa_core::Schema,
}

impl Declarations {
    /// A store that can reach these templates and nothing else.
    fn with(
        renderer: crate::template::Renderer,
        fleet: crate::fleet::Fleet,
        schema: nerpa_core::Schema,
    ) -> Self {
        Self {
            fleet,
            renderer,
            resources: RefCell::new(Vec::new()),
            nodes: RefCell::new(Vec::new()),
            moves: RefCell::new(Vec::new()),
            problems: RefCell::new(Vec::new()),
            required_version: RefCell::new(None),
            schema,
        }
    }

    /// What the providers say about their kinds, for a refusal that belongs to
    /// one of them.
    pub(crate) fn schema(&self) -> &nerpa_core::Schema {
        &self.schema
    }

    /// The renderer, for the one global that needs it.
    pub(crate) fn renderer(&self) -> &crate::template::Renderer {
        &self.renderer
    }

    /// What is written down about the machines.
    pub(crate) fn fleet(&self) -> &crate::fleet::Fleet {
        &self.fleet
    }

    pub(crate) fn add_resource(&self, resource: Resource) {
        self.resources.borrow_mut().push(resource);
    }

    pub(crate) fn add_node(&self, target: Target, provided_by: Option<Address>) {
        self.nodes.borrow_mut().push((target, provided_by));
    }

    pub(crate) fn add_moved(&self, from: Address, to: Address) {
        self.moves.borrow_mut().push((from, to));
    }

    pub(crate) fn add_problem(&self, problem: String) {
        self.problems.borrow_mut().push(problem);
    }

    /// Records which nerpa version the configuration declared it expects.
    pub(crate) fn require_version(&self, version: &str) {
        self.required_version
            .borrow_mut()
            .replace(version.to_owned());
    }

    /// What the configuration said it expects, if it said anything.
    pub(crate) fn required_version(&self) -> Option<String> {
        self.required_version.borrow().clone()
    }
}

/// Turns a configuration into a graph, or explains why it cannot.
///
/// # Errors
///
/// Returns [`ConfigError`] if the program does not parse, fails while running,
/// declares something the resource model refuses, branches on a value that does
/// not exist yet, or produces declarations that cannot be ordered.
#[allow(
    clippy::result_large_err,
    reason = "diagnostics beat a lint on a cold path"
)]
pub fn evaluate(
    path: &str,
    source: &str,
    templates: Box<dyn crate::template::Templates>,
    fleet: crate::fleet::Fleet,
) -> Result<Graph, ConfigError> {
    evaluate_with(path, source, templates, fleet, nerpa_core::Schema::new())
}

/// The same, knowing what the providers say about their kinds.
///
/// A schema is how a refusal that belongs to a provider, such as a package
/// reacting, is made where the configuration says it, with its line, rather than
/// when planning.
///
/// # Errors
///
/// As [`evaluate`], and a reaction given to a kind that cannot react.
#[allow(
    clippy::result_large_err,
    reason = "diagnostics beat a lint on a cold path"
)]
pub fn evaluate_with(
    path: &str,
    source: &str,
    templates: Box<dyn crate::template::Templates>,
    fleet: crate::fleet::Fleet,
    schema: nerpa_core::Schema,
) -> Result<Graph, ConfigError> {
    // Top-level statements are enabled because this is a configuration, not a
    // build file: `for` and `if` at module level are how anybody would write it.
    // Everything else stays standard — no `while`, no recursion — so evaluation
    // always terminates, which is what lets a plan be computed before anything is
    // touched.
    let dialect = Dialect {
        enable_top_level_stmt: true,
        ..Dialect::Standard
    };
    let ast = AstModule::parse(path, source.to_owned(), &dialect)
        .map_err(|error| ConfigError::Starlark(format!("{error:?}")))?;

    // Before anything runs: two comparisons that evaluation would let through
    // silently. See `refuse` for why they cannot be caught any later.
    if let Some(refusal) = refuse::comparisons(&ast).into_iter().next() {
        return Err(ConfigError::ComparedUnresolved(refusal));
    }

    let globals = GlobalsBuilder::standard().with(globals::nerpa).build();
    let declarations = Declarations::with(crate::template::Renderer::new(templates), fleet, schema);

    handle::forget_branches();
    Module::with_temp_heap(|module| {
        let mut evaluator = Evaluator::new(&module);
        evaluator.extra = Some(&declarations);
        evaluator.eval_module(ast, &globals)?;
        // The module's own value never leaves: it is tied to the temporary heap,
        // and everything worth keeping was recorded in `declarations` on the way
        // through.
        starlark::Result::Ok(())
    })
    .map_err(|error| ConfigError::Starlark(format!("{error:?}")))?;

    // Checked before anything else is reported: a run that branched on a value
    // which does not exist yet produced a graph nobody should trust, whatever
    // else it also got wrong.
    if let Some(reference) = handle::branches_taken().first() {
        return Err(ConfigError::BranchedOnUnresolved(reference.clone()));
    }

    if let Some(problem) = declarations.problems.borrow().first() {
        return Err(ConfigError::Declaration(problem.clone()));
    }

    // The configuration names the nerpa build it was written against; a
    // different one refuses before anything is touched (decision 0022).
    if let Some(required) = declarations.required_version() {
        let current = env!("CARGO_PKG_VERSION");
        if required != current {
            return Err(ConfigError::VersionMismatch(required, current.to_owned()));
        }
    }

    let mut graph = Graph::new();
    for resource in declarations.resources.take() {
        graph.insert(resource)?;
    }
    for (target, provided_by) in declarations.nodes.take() {
        match provided_by {
            Some(address) => graph.managed_by(target, address),
            None => graph.preexisting(target),
        }
    }
    for (from, to) in declarations.moves.take() {
        graph = graph.moved(from, to);
    }
    Ok(graph)
}