Skip to main content

vynil_core/
lib.rs

1//! # vynil-core
2//!
3//! Generic Rust toolbox combining a [Rhai] scripting engine and a
4//! [Handlebars] templating engine, with optional Kubernetes / OCI / S3 / HTTP handlers.
5//!
6//! Extracted from the [vynil](https://github.com/sebt3/vynil) workspace so it can be reused
7//! by other projects (`kuberest`, `kydah`, …) without pulling in vynil's business abstractions
8//! (CRDs, package model, instance controllers).
9//!
10//! > **Status:** API is not yet stable — expect breaking changes before `1.0`.
11//!
12//! # Features
13//!
14//! | Feature | Default | What it adds |
15//! |---------|---------|--------------|
16//! | `rhai` | ✅ | [`engine::Script`] engine + every other module's Rhai bindings |
17//! | `hbs` | ✅ | [`hbs::HandleBars`] engine |
18//! | `hbs-scripting` | ✅ | `register_helper_dir` / `rhai_register_helper_dir` (`handlebars/script_helper`). Implies `hbs` + `rhai`. Keep it separate because `script_helper` pulls `smartstring` which breaks `String + &String` in some graphs (see `hbs` docs) |
19//! | `http` | ✅ | [`http::RestClient`] (reqwest) + [`http_mock::RestClientMock`]. Implies `rhai` |
20//! | `crypto` | ✅ | `argon_hash` / `bcrypt_hash` / `gen_private_key` helpers (Handlebars + Rhai) |
21//! | `k8s` | ❌ | Generic K8s handlers ([`k8s::K8sGeneric`], [`k8s::K8sObject`], …) + mocks. Implies `rhai` |
22//! | `oci` | ❌ | [`oci::Registry`] + OCI mock. Implies `rhai` |
23//! | `s3` | ❌ | S3 helpers ([`s3::s3_get_yaml`], [`s3::s3_list_keys`]). Implies `rhai` |
24//! | `fs` | ❌ | Filesystem access from Rhai (`file_read`, `file_write`, …) |
25//! | `shell` | ❌ | Shell execution (`shell::run` / `shell::get_out` + Rhai `shell_run`) |
26//! | `password` | ❌ | `gen_password` / `gen_password_alphanum` (opt-in to avoid name collisions) |
27//!
28//! ```toml
29//! # default: Rhai + Handlebars + HTTP + crypto
30//! vynil-core = "0.7"
31//! # Kubernetes project
32//! vynil-core = { version = "0.7", features = ["k8s"] }
33//! # Handlebars only, no Rhai/smartstring in the graph
34//! vynil-core = { version = "0.7", default-features = false, features = ["hbs", "crypto"] }
35//! ```
36//!
37//! # Quick start
38//!
39//! ```rust,no_run
40//! vynil_core::set_client_name(|| "my-app.example.com".to_string());
41//!
42//! // Rhai
43//! let mut script = vynil_core::engine::Script::new_bare(vec!["scripts/".into()]);
44//! script.engine.register_fn("my_fn", |s: String| s.len() as i64);
45//! // script.run_file(&std::path::PathBuf::from("scripts/run.rhai"))?;
46//!
47//! // Handlebars
48//! let mut hbs = vynil_core::hbs::HandleBars::new();
49//! let out = hbs.render("Hello {{ name }}!", &serde_json::json!({"name": "world"})).unwrap();
50//! assert_eq!(out, "Hello world!");
51//! # Ok::<(), vynil_core::Error>(())
52//! ```
53//!
54//! # Client identity
55//!
56//! `vynil-core` does not assume an identity. Call `set_client_name` once at startup
57//! before any HTTP or Kubernetes call, otherwise those calls panic with an actionable message.
58//!
59//! ```rust
60//! vynil_core::set_client_name(|| "my-app.example.com".to_string());
61//! assert!(vynil_core::client_name_is_set());
62//! ```
63//!
64//! # Rhai helpers (injected by [`engine::Script::new_bare`])
65//!
66//! Common: `sha256`, `log_debug/info/warn/error`, `url_encode`, `get_env`, `to_decimal`,
67//! `base64_encode/decode`, `json_encode/decode`, `basename`, `dirname`.
68//! Additional, feature-gated: `yaml_encode/decode`, `semver_from` + `inc_*`, `glob`,
69//! `date_now`/`format`, `crc32_hash`/`bcrypt_hash`/`argon`, `gen_private_key`,
70//! `gen_password` (feature `password`), `file_*` (feature `fs`), `shell_*` (feature `shell`),
71//! `Registry` / `s3_*` / `RestClient` / `k8s_*` when their feature is enabled.
72//!
73//! Scripts also get `assert` and `import_run` / `import_template` shims for optional imports.
74//!
75//! # Handlebars helpers (injected by [`hbs::HandleBars::new`])
76//!
77//! See [`hbs::CORE_HBS_HELPERS`] for the full list. Highlights: `base64_encode/decode`,
78//! `url_encode`, `to_decimal`, `header_basic`, `crc32_hash`, `argon_hash`/`bcrypt_hash`/`gen_private_key`
79//! (feature `crypto`), `gen_password*` (feature `password`), plus the `handlebars_misc_helpers`
80//! set and the vendored `json_to_str` / `str_to_json` / `json_query` family.
81//!
82//! # Crate boundaries
83//!
84//! This crate stays generic: no dependency on `vynil`, `kuberest` or `kydah`, no default
85//! client name, no CRDs or vynil-specific Handlebars helpers.
86//!
87//! [Rhai]: https://rhai.rs
88//! [Handlebars]: https://handlebarsjs.com
89
90#![cfg_attr(docsrs, feature(doc_cfg))]
91
92use thiserror::Error;
93
94/// Errors returned by `vynil-core` operations.
95///
96/// Variants behind feature gates are only available when that feature is enabled.
97#[derive(Error, Debug)]
98pub enum Error {
99    /// JSON serialization / deserialization failure.
100    #[error("SerializationError: {0}")]
101    SerializationError(#[from] serde_json::Error),
102
103    /// YAML parsing / serialisation failure. Payload is the underlying error string.
104    #[error("YamlError: {0}")]
105    YamlError(String),
106
107    #[cfg(feature = "hbs")]
108    #[cfg_attr(docsrs, doc(cfg(feature = "hbs")))]
109    #[error("Registering template failed with error: {0}")]
110    HbsTemplateError(#[from] handlebars::TemplateError),
111
112    #[cfg(feature = "hbs")]
113    #[cfg_attr(docsrs, doc(cfg(feature = "hbs")))]
114    #[error("Renderer error: {0}")]
115    HbsRenderError(#[from] handlebars::RenderError),
116
117    #[cfg(feature = "rhai")]
118    #[cfg_attr(docsrs, doc(cfg(feature = "rhai")))]
119    #[error("Rhai script error: {0}")]
120    RhaiError(#[from] Box<rhai::EvalAltResult>),
121
122    #[cfg(feature = "http")]
123    #[cfg_attr(docsrs, doc(cfg(feature = "http")))]
124    #[error("Reqwest error: {0}")]
125    ReqwestError(#[from] reqwest::Error),
126
127    /// JSON decoding of an HTTP body failed.
128    #[error("Json decoding error: {0}")]
129    JsonError(#[source] serde_json::Error),
130
131    /// An HTTP call returned a non-success status.
132    #[error("{0} query failed: {1}")]
133    MethodFailed(String, u16, String),
134
135    /// `RestClient::obj_*` was called with an unsupported method enum variant.
136    #[error("Unsupported method")]
137    UnsupportedMethod,
138
139    /// Script file not found on disk.
140    #[error("Missing script {0}")]
141    MissingScript(std::path::PathBuf),
142
143    /// UTF-8 conversion failure.
144    #[error("UTF8 error {0}")]
145    UTF8(#[from] std::string::FromUtf8Error),
146
147    /// Semver parsing failure.
148    #[error("Semver error {0}")]
149    Semver(#[from] ::semver::Error),
150
151    #[cfg(feature = "crypto")]
152    #[cfg_attr(docsrs, doc(cfg(feature = "crypto")))]
153    #[error("Argon2 password_hash error {0}")]
154    Argon2hash(#[from] argon2::password_hash::Error),
155
156    #[cfg(feature = "crypto")]
157    #[cfg_attr(docsrs, doc(cfg(feature = "crypto")))]
158    #[error("Bcrypt hash error {0}")]
159    BcryptError(#[from] bcrypt::BcryptError),
160
161    /// I/O error.
162    #[error("Stdio error {0}")]
163    Stdio(#[from] std::io::Error),
164
165    /// Base64 decoding failure.
166    #[error("Base64 decode error {0}")]
167    Base64DecodeError(#[from] base64::DecodeError),
168
169    /// Building a raw HTTP request failed.
170    #[error("RAW api error {0}")]
171    RawHTTP(#[from] ::http::Error),
172
173    /// Integer parsing failure.
174    #[error("ParseIntError {0}")]
175    ParseInt(#[from] std::num::ParseIntError),
176
177    #[cfg(feature = "crypto")]
178    #[cfg_attr(docsrs, doc(cfg(feature = "crypto")))]
179    #[error("KEY-OPENSSL-001 OpenSSL error {0}")]
180    OpenSSL(#[from] openssl::error::ErrorStack),
181
182    /// `gen_private_key` was called with an unknown algorithm.
183    #[error("KEY-ALGO-001 Unsupported key algorithm: {0}")]
184    UnsupportedKeyAlgorithm(String),
185
186    /// Password generation spec was invalid.
187    #[error("{0}")]
188    PasswordSpec(String),
189
190    /// Catch-all.
191    #[error("Error: {0}")]
192    Other(String),
193
194    #[cfg(feature = "oci")]
195    #[cfg_attr(docsrs, doc(cfg(feature = "oci")))]
196    #[error("OCI jukebox error {0}")]
197    OCIDistrib(#[from] oci_client::errors::OciDistributionError),
198
199    #[cfg(feature = "oci")]
200    #[cfg_attr(docsrs, doc(cfg(feature = "oci")))]
201    #[error("OCI parse error {0}")]
202    OCIParseError(#[from] oci_client::ParseError),
203
204    #[cfg(feature = "k8s")]
205    #[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
206    #[error("K8s error: {0}")]
207    KubeError(#[from] kube::Error),
208
209    #[cfg(feature = "k8s")]
210    #[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
211    #[error("K8s wait error: {0}")]
212    KubeWaitError(#[from] kube::runtime::wait::Error),
213
214    #[cfg(feature = "k8s")]
215    #[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
216    #[error("Elapsed wait error: {0}")]
217    Elapsed(#[from] tokio::time::error::Elapsed),
218
219    #[cfg(feature = "k8s")]
220    #[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
221    #[error("Finalizer error: {0}")]
222    FinalizerError(#[from] Box<kube::runtime::finalizer::Error<Error>>),
223}
224
225/// Crate result type. `E` defaults to [`enum@Error`].
226pub type Result<T, E = Error> = std::result::Result<T, E>;
227
228/// Result type used by Rhai-exposed functions. Alias for `Result<T, Box<EvalAltResult>>`.
229#[cfg(feature = "rhai")]
230#[cfg_attr(docsrs, doc(cfg(feature = "rhai")))]
231pub type RhaiRes<T> = std::result::Result<T, Box<rhai::EvalAltResult>>;
232
233/// Render an error together with its full `source()` chain, e.g.
234/// `error sending request for url (...): dns error: failed to lookup address information: ...`.
235///
236/// Several error types this crate surfaces to Rhai — most notably `reqwest::Error` for a
237/// connection-level failure (DNS, TLS, timeout, connection refused) — implement [`std::fmt::Display`]
238/// on the outer error only, leaving the actual cause reachable solely through `Error::source()`
239/// (i.e. visible in `{:?}` but not `{}`). Without walking the chain, a script (and whatever surfaces
240/// its error, e.g. a JukeBox `Updated` condition) only ever sees an opaque
241/// "error sending request for url (...)" with no indication of *why* the request failed.
242/// A source already restating an ancestor's message verbatim (e.g. `Error::ReqwestError`'s
243/// `#[error("Reqwest error: {0}")]` Display, which embeds its wrapped `reqwest::Error`'s own
244/// Display) is skipped rather than appended again.
245pub fn error_chain(err: &(dyn std::error::Error + 'static)) -> String {
246    let mut acc = err.to_string();
247    let mut source = err.source();
248    while let Some(e) = source {
249        let msg = e.to_string();
250        if !acc.contains(&msg) {
251            acc.push_str(": ");
252            acc.push_str(&msg);
253        }
254        source = e.source();
255    }
256    acc
257}
258
259/// Convert a [`enum@Error`] into a Rhai `EvalAltResult`, including its full `source()` chain
260/// (see [`error_chain`]) so the real cause of a connection-level failure isn't swallowed.
261#[cfg(feature = "rhai")]
262pub fn rhai_err(e: Error) -> Box<rhai::EvalAltResult> {
263    error_chain(&e).into()
264}
265
266/// Convert a string into a Rhai `EvalAltResult`.
267#[cfg(feature = "rhai")]
268pub fn rhai_err_str(e: String) -> Box<rhai::EvalAltResult> {
269    e.into()
270}
271
272/// Date/time helpers (`DateTimeHandler`).
273pub mod chrono;
274/// Global client identity (`User-Agent` / field-manager).
275pub mod client_name;
276/// Hash helpers (crc32, bcrypt, argon2).
277pub mod hashes;
278/// Password generation.
279pub mod password;
280/// Semver parsing and mutation.
281pub mod semver;
282/// YAML ↔ JSON helpers.
283pub mod yaml;
284
285#[cfg(feature = "crypto")]
286#[cfg_attr(docsrs, doc(cfg(feature = "crypto")))]
287/// Private key generation (RSA / ed25519 via OpenSSL).
288pub mod key;
289
290#[cfg(feature = "rhai")]
291#[cfg_attr(docsrs, doc(cfg(feature = "rhai")))]
292/// Rhai scripting engine ([`engine::Script`]) and its registered helpers.
293pub mod engine;
294#[cfg(feature = "rhai")]
295#[cfg_attr(docsrs, doc(cfg(feature = "rhai")))]
296/// Glob matching (`glob` Rhai helper).
297pub mod glob;
298
299#[cfg(feature = "hbs")]
300#[cfg_attr(docsrs, doc(cfg(feature = "hbs")))]
301/// Handlebars templating engine ([`hbs::HandleBars`]) and its helpers.
302pub mod hbs;
303#[cfg(feature = "hbs")] mod hbs_json;
304
305#[cfg(feature = "http")]
306#[cfg_attr(docsrs, doc(cfg(feature = "http")))]
307/// HTTP client ([`http::RestClient`]).
308pub mod http;
309#[cfg(feature = "http")]
310#[cfg_attr(docsrs, doc(cfg(feature = "http")))]
311/// Mock HTTP client for tests ([`http_mock::RestClientMock`]).
312pub mod http_mock;
313
314#[cfg(feature = "oci")]
315#[cfg_attr(docsrs, doc(cfg(feature = "oci")))]
316/// OCI registry client ([`oci::Registry`]).
317pub mod oci;
318#[cfg(feature = "oci")]
319#[cfg_attr(docsrs, doc(cfg(feature = "oci")))]
320/// Mock OCI helpers.
321pub mod oci_mock;
322
323#[cfg(feature = "s3")]
324#[cfg_attr(docsrs, doc(cfg(feature = "s3")))]
325/// S3 helpers (`s3_get_yaml`, `s3_list_keys`).
326pub mod s3;
327
328#[cfg(feature = "k8s")]
329#[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
330/// Kubernetes handlers (`K8sGeneric`, `K8sObject`, …).
331pub mod k8s;
332#[cfg(feature = "k8s")]
333#[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
334/// Mock Kubernetes helpers.
335pub mod k8s_mock;
336
337#[cfg(feature = "shell")]
338#[cfg_attr(docsrs, doc(cfg(feature = "shell")))]
339/// Shell execution helpers.
340pub mod shell;
341
342pub use client_name::{client_name_is_set, get_client_name, set_client_name};
343pub use semver::Semver;
344
345#[cfg(feature = "rhai")]
346#[cfg_attr(docsrs, doc(cfg(feature = "rhai")))]
347pub use engine::Script;
348#[cfg(feature = "hbs")]
349#[cfg_attr(docsrs, doc(cfg(feature = "hbs")))]
350pub use hbs::HandleBars;
351
352#[cfg(feature = "k8s")]
353#[cfg_attr(docsrs, doc(cfg(feature = "k8s")))]
354pub use k8s::update_cache;
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::fmt;
360
361    #[derive(Debug)]
362    struct Layered {
363        msg: &'static str,
364        source: Option<Box<Layered>>,
365    }
366    impl fmt::Display for Layered {
367        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368            write!(f, "{}", self.msg)
369        }
370    }
371    impl std::error::Error for Layered {
372        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
373            self.source
374                .as_deref()
375                .map(|e| e as &(dyn std::error::Error + 'static))
376        }
377    }
378
379    #[test]
380    fn error_chain_walks_every_source() {
381        let err = Layered {
382            msg: "error sending request for url (https://gitlab.com/api/v4/projects)",
383            source: Some(Box::new(Layered {
384                msg: "dns error: failed to lookup address information",
385                source: Some(Box::new(Layered {
386                    msg: "Temporary failure in name resolution",
387                    source: None,
388                })),
389            })),
390        };
391        assert_eq!(
392            error_chain(&err),
393            "error sending request for url (https://gitlab.com/api/v4/projects): \
394             dns error: failed to lookup address information: \
395             Temporary failure in name resolution"
396        );
397    }
398
399    #[test]
400    fn error_chain_collapses_a_duplicate_leading_source() {
401        // Mirrors `Error::ReqwestError`, whose `#[error("Reqwest error: {0}")]` Display already
402        // embeds its wrapped source's own message verbatim.
403        let inner = Layered {
404            msg: "error sending request for url (https://gitlab.com/api/v4/projects)",
405            source: None,
406        };
407        let outer = Layered {
408            msg: "Reqwest error: error sending request for url (https://gitlab.com/api/v4/projects)",
409            source: Some(Box::new(Layered {
410                msg: "error sending request for url (https://gitlab.com/api/v4/projects)",
411                source: None,
412            })),
413        };
414        assert_eq!(error_chain(&inner), inner.msg);
415        assert_eq!(
416            error_chain(&outer),
417            outer.msg,
418            "duplicate source line must be collapsed"
419        );
420    }
421
422    #[test]
423    fn error_chain_single_error_has_no_source() {
424        let err = Layered {
425            msg: "boom",
426            source: None,
427        };
428        assert_eq!(error_chain(&err), "boom");
429    }
430}