feat: add variables

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2024-05-20 23:05:20 +02:00
parent f71b68cd89
commit bbe630e822
Signed by: kjuulh
GPG Key ID: 57B6E1465221F912
14 changed files with 429 additions and 22 deletions

29
Cargo.lock generated
View File

@ -388,6 +388,7 @@ dependencies = [
"axum",
"clap",
"dotenv",
"futures",
"minijinja",
"serde",
"serde_yaml",
@ -533,6 +534,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.30"
@ -577,6 +593,17 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
[[package]]
name = "futures-macro"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.65",
]
[[package]]
name = "futures-sink"
version = "0.3.30"
@ -595,8 +622,10 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",

View File

@ -27,6 +27,7 @@ serde_yaml = "0.9.34"
tokio-stream = "0.1.15"
walkdir = "2.5.0"
minijinja = "2.0.1"
futures = "0.3.30"
[[test]]
name = "integration"

View File

@ -0,0 +1 @@
pub mod cuddle_vars;

View File

@ -0,0 +1,160 @@
use std::{collections::HashMap, ops::Deref, path::Path};
use anyhow::Context;
use futures::{future::BoxFuture, FutureExt};
use crate::Component;
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum CuddleVariable {
Object(Box<CuddleVariables>),
Array(Vec<CuddleVariable>),
String(String),
}
impl TryFrom<serde_yaml::Value> for CuddleVariable {
type Error = anyhow::Error;
fn try_from(value: serde_yaml::Value) -> Result<Self, Self::Error> {
match value {
serde_yaml::Value::Sequence(sequence) => {
let mut items = Vec::new();
for item in sequence {
items.push(item.try_into()?)
}
Ok(Self::Array(items))
}
serde_yaml::Value::Mapping(mapping) => {
let obj: CuddleVariables = mapping.try_into()?;
Ok(Self::Object(Box::new(obj)))
}
serde_yaml::Value::String(s) => Ok(Self::String(s)),
serde_yaml::Value::Number(num) => Ok(Self::String(num.to_string())),
serde_yaml::Value::Bool(bool) => Ok(Self::String(bool.to_string())),
_ => Err(anyhow::anyhow!("cannot handle type of serde value")),
}
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct CuddleVariables(pub HashMap<String, CuddleVariable>);
impl TryFrom<serde_yaml::Mapping> for CuddleVariables {
type Error = anyhow::Error;
fn try_from(value: serde_yaml::Mapping) -> Result<Self, Self::Error> {
let mut variables = CuddleVariables(HashMap::default());
for (k, v) in value {
let var: CuddleVariable = v.try_into()?;
variables.0.insert(
k.as_str()
.ok_or(anyhow::anyhow!("key cannot be anything else than a string"))?
.to_string(),
var,
);
}
Ok(variables)
}
}
impl TryFrom<serde_yaml::Value> for CuddleVariables {
type Error = anyhow::Error;
fn try_from(value: serde_yaml::Value) -> Result<Self, Self::Error> {
match value {
serde_yaml::Value::Null => anyhow::bail!("cannot handle null"),
serde_yaml::Value::Bool(_) => anyhow::bail!("cannot handle bool"),
serde_yaml::Value::Number(_) => anyhow::bail!("cannot handle number"),
serde_yaml::Value::String(_) => anyhow::bail!("cannot handle string"),
serde_yaml::Value::Sequence(_) => anyhow::bail!("cannot handle sequence"),
serde_yaml::Value::Mapping(mapping) => mapping.try_into(),
serde_yaml::Value::Tagged(_) => anyhow::bail!("cannot handle tagged"),
}
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct CuddleVars {
pub variables: CuddleVariables,
}
const PARENT_PLAN_PREFIX: &str = ".cuddle/plan";
const CUDDLE_FILE: &str = "cuddle.yaml";
impl CuddleVars {
pub async fn new(path: &Path) -> anyhow::Result<CuddleVars> {
let variables = load_cuddle_file(path).await?;
Ok(Self { variables })
}
}
fn load_cuddle_file(path: &Path) -> BoxFuture<'static, anyhow::Result<CuddleVariables>> {
let path = path.to_path_buf();
async move {
let cuddle_file = path.join(CUDDLE_FILE);
let cuddle_file_content = tokio::fs::read(cuddle_file)
.await
.context(format!("failed to read cuddle file: {}", path.display()))?;
let cuddle_serde: serde_yaml::Value = serde_yaml::from_slice(&cuddle_file_content)?;
match cuddle_serde.get("vars") {
Some(vars) => {
let parent_file = path.join(PARENT_PLAN_PREFIX);
if parent_file.exists() {
// FIXME: Merge parent map
//let parent_plan = load_cuddle_file(&parent_file).await?;
//Err(anyhow::anyhow!("not implemented yet"))
Ok(CuddleVariables::try_from(vars.clone())?)
} else {
Ok(CuddleVariables::try_from(vars.clone())?)
}
}
None => Err(anyhow::anyhow!(
"failed to find variables section in cuddle.yaml"
)),
}
}
.boxed()
}
impl Component for CuddleVars {
fn name(&self) -> String {
"cuddle/vars".into()
}
fn validate(&self, _value: &serde_yaml::Value) -> anyhow::Result<()> {
Ok(())
}
fn render_value(&self, _value: &serde_yaml::Value) -> Option<anyhow::Result<minijinja::Value>> {
Some(Ok(minijinja::Value::from_object(self.variables.clone())))
}
}
impl minijinja::value::Object for CuddleVariables {
fn get_value(self: &std::sync::Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
if let Some(key) = key.as_str() {
tracing::trace!("looking up key: {}", key);
if let Some(val) = self.0.get(key) {
match val {
CuddleVariable::Object(_) => todo!(),
CuddleVariable::Array(_) => todo!(),
CuddleVariable::String(str) => {
return Some(minijinja::Value::from_safe_string(str.to_owned()))
}
}
}
}
None
}
}

View File

@ -0,0 +1,75 @@
use std::rc::Rc;
pub trait Component {
fn name(&self) -> String;
fn validate(&self, _value: &serde_yaml::Value) -> anyhow::Result<()> {
Ok(())
}
fn render_value(&self, _value: &serde_yaml::Value) -> Option<anyhow::Result<minijinja::Value>> {
None
}
/// First return is name, second is contents
fn render(&self, _value: &serde_yaml::Value) -> Option<anyhow::Result<(String, String)>> {
None
}
}
#[derive(Clone)]
pub struct ConcreteComponent {
inner: Rc<dyn Component + 'static>,
}
impl std::ops::Deref for ConcreteComponent {
type Target = Rc<dyn Component + 'static>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl ConcreteComponent {
pub fn new<T: Component + 'static>(t: T) -> Self {
Self { inner: Rc::new(t) }
}
}
pub trait IntoComponent {
fn into_component(self) -> ConcreteComponent;
}
impl IntoComponent for ConcreteComponent {
fn into_component(self) -> ConcreteComponent {
self
}
}
impl<T: Component + 'static> IntoComponent for T {
fn into_component(self) -> ConcreteComponent {
ConcreteComponent::new(self)
}
}
#[cfg(test)]
mod test {
use similar_asserts::assert_eq;
use super::*;
pub struct Database {}
impl Component for Database {
fn name(&self) -> String {
"cuddle/database".into()
}
}
#[test]
fn can_transform_into_concrete() {
let database = Database {};
assert_eq!("cuddle/database", database.into_component().name());
}
}

View File

@ -1,3 +1,7 @@
pub mod process;
pub mod components;
pub use components::*;
pub mod catalog;
pub mod process;
pub use process::{process, process_opts};

View File

@ -9,8 +9,10 @@ use minijinja::context;
use tokio::io::AsyncWriteExt;
use tokio_stream::{wrappers::ReadDirStream, StreamExt};
use crate::components::{Component, ConcreteComponent, IntoComponent};
pub async fn process() -> anyhow::Result<()> {
process_opts(ProcessOpts::default()).await
process_opts(Vec::<ConcreteComponent>::new(), ProcessOpts::default()).await
}
pub struct ProcessOpts {
@ -30,7 +32,15 @@ impl Default for ProcessOpts {
}
}
pub async fn process_opts(opts: ProcessOpts) -> anyhow::Result<()> {
pub async fn process_opts(
components: impl IntoIterator<Item = impl IntoComponent>,
opts: ProcessOpts,
) -> anyhow::Result<()> {
let components = components
.into_iter()
.map(|c| c.into_component())
.collect::<Vec<_>>();
let path = opts.path.canonicalize().context("failed to find folder")?;
let cuddle_path = path.join("cuddle.yaml");
@ -51,7 +61,7 @@ pub async fn process_opts(opts: ProcessOpts) -> anyhow::Result<()> {
tokio::fs::remove_dir_all(&opts.output).await?;
tokio::fs::create_dir_all(&opts.output).await?;
process_templates(&clusters, &template_files, &opts.output).await?;
process_templates(&components, &clusters, &template_files, &opts.output).await?;
Ok(())
}
@ -122,35 +132,47 @@ async fn read_dir(path: &Path) -> anyhow::Result<Vec<PathBuf>> {
}
async fn process_templates(
components: &Vec<ConcreteComponent>,
clusters: &CuddleClusters,
template_files: &TemplateFiles,
dest: &Path,
) -> anyhow::Result<()> {
for (cluster_name, _value) in clusters.iter() {
process_cluster(cluster_name, template_files, &dest.join(cluster_name)).await?;
for (environment, value) in clusters.iter() {
process_cluster(
&components,
value,
environment,
template_files,
&dest.join(environment),
)
.await?;
}
Ok(())
}
async fn process_cluster(
cluster_name: &str,
components: &Vec<ConcreteComponent>,
value: &serde_yaml::Value,
environment: &str,
template_files: &TemplateFiles,
dest: &Path,
) -> anyhow::Result<()> {
for template_file in &template_files.templates {
process_template_file(cluster_name, template_file, dest).await?;
process_template_file(components, value, environment, template_file, dest).await?;
}
for raw_file in &template_files.raw {
process_raw_file(cluster_name, raw_file, dest).await?;
process_raw_file(environment, raw_file, dest).await?;
}
Ok(())
}
async fn process_template_file(
_cluster_name: &str,
components: &Vec<ConcreteComponent>,
value: &serde_yaml::Value,
environment: &str,
template_file: &PathBuf,
dest: &Path,
) -> anyhow::Result<()> {
@ -175,8 +197,23 @@ async fn process_template_file(
"failed to load template at: {}",
template_file.display()
))?;
env.add_global("environment", environment);
let mut variables = HashMap::new();
for component in components {
let name = component.name();
if let Some(value) = component.render_value(value) {
let value = value?;
variables.insert(name.replace("/", "_"), value);
}
}
let tmpl = env.get_template(file_name.to_str().unwrap_or_default())?;
let rendered = tmpl.render(context! {})?;
let rendered = tmpl.render(context! {
vars => variables
})?;
dest_file.write_all(rendered.as_bytes()).await?;

View File

@ -1,9 +1,12 @@
use std::{cmp::Ordering, path::Path};
use cuddle_clusters::process::ProcessOpts;
use cuddle_clusters::{process::ProcessOpts, ConcreteComponent, IntoComponent};
use walkdir::DirEntry;
pub(crate) async fn run_test(name: &str) -> anyhow::Result<()> {
pub(crate) async fn run_test_with_components(
name: &str,
components: Vec<impl IntoComponent>,
) -> anyhow::Result<()> {
let _ = tracing_subscriber::fmt::try_init();
println!("running for: {name}");
@ -17,17 +20,25 @@ pub(crate) async fn run_test(name: &str) -> anyhow::Result<()> {
let expected = test_folder.join("expected");
tokio::fs::create_dir_all(&expected).await?;
cuddle_clusters::process_opts(ProcessOpts {
let components: Vec<_> = components.into_iter().map(|p| p.into_component()).collect();
cuddle_clusters::process_opts(
components.clone(),
ProcessOpts {
path: test_folder.clone(),
output: actual.clone(),
})
},
)
.await?;
if std::env::var("TEST_OVERRIDE") == Ok("true".to_string()) {
cuddle_clusters::process_opts(ProcessOpts {
cuddle_clusters::process_opts(
components,
ProcessOpts {
path: test_folder,
output: expected.clone(),
})
},
)
.await?;
}
@ -36,6 +47,10 @@ pub(crate) async fn run_test(name: &str) -> anyhow::Result<()> {
Ok(())
}
pub(crate) async fn run_test(name: &str) -> anyhow::Result<()> {
run_test_with_components(name, Vec::<ConcreteComponent>::new()).await
}
async fn compare(expected: &Path, actual: &Path) -> anyhow::Result<()> {
let mut exp = walk_dir(expected)?;
let mut act = walk_dir(actual)?;

View File

@ -0,0 +1,49 @@
use std::collections::HashMap;
use cuddle_clusters::catalog::cuddle_vars::{CuddleVariable, CuddleVariables, CuddleVars};
use similar_asserts::assert_eq;
#[tokio::test]
async fn can_load_cuddle_file() -> anyhow::Result<()> {
let current_dir = std::env::current_dir()?.join("tests");
let vars = CuddleVars::new(&current_dir.join("cuddle_vars/basic/")).await?;
assert_eq!(
CuddleVars {
variables: CuddleVariables(HashMap::from([
("service".into(), CuddleVariable::String("basic".into())),
(
"some".into(),
CuddleVariable::Object(Box::new(CuddleVariables(HashMap::from([
("other".into(), CuddleVariable::String("item".into())),
(
"nested".into(),
CuddleVariable::Object(Box::new(CuddleVariables(HashMap::from([(
"item".into(),
CuddleVariable::String("item".into())
)]))))
),
(
"array".into(),
CuddleVariable::Array(vec![CuddleVariable::Object(Box::new(
CuddleVariables(HashMap::from([(
"item".into(),
CuddleVariable::Object(Box::new(CuddleVariables(
HashMap::from([(
"item".into(),
CuddleVariable::String("item".into()),
)])
)))
)]))
))])
)
]))))
)
]))
},
vars
);
Ok(())
}

View File

@ -0,0 +1,10 @@
vars:
service: basic
some:
other: item
nested:
item: item
array:
- item:
item: item

View File

@ -1,8 +1,11 @@
pub mod common;
mod can_run_for_env;
mod cuddle_vars;
use crate::common::run_test;
use cuddle_clusters::catalog::cuddle_vars::CuddleVars;
use crate::common::{run_test, run_test_with_components};
#[tokio::test]
async fn raw_files() -> anyhow::Result<()> {
@ -37,3 +40,16 @@ async fn environment() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn with_cuddle_vars() -> anyhow::Result<()> {
let current_dir = std::env::current_dir()?.join("tests/with_cuddle_vars");
run_test_with_components(
"with_cuddle_vars",
vec![CuddleVars::new(&current_dir).await?],
)
.await?;
Ok(())
}

View File

@ -0,0 +1,5 @@
vars:
service: service
cuddle/clusters:
dev:

View File

@ -0,0 +1,2 @@
service: service

View File

@ -0,0 +1,3 @@
service: {{ vars.cuddle_vars.service }}