From e5c8089b8c1301f6807e2fadc960a05eb8ec6baf Mon Sep 17 00:00:00 2001 From: Laurentiu Ciobanu Date: Fri, 12 Sep 2025 14:05:00 +0000 Subject: [PATCH 1/2] expr context improvements --- src/cli/cmd/deploy.rs | 117 +++++++------------------------------- src/cli/expr/ctx.rs | 80 +++++++++++++++++++++----- src/cli/expr/eval.rs | 127 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 113 deletions(-) diff --git a/src/cli/cmd/deploy.rs b/src/cli/cmd/deploy.rs index 3a45416..35a111c 100644 --- a/src/cli/cmd/deploy.rs +++ b/src/cli/cmd/deploy.rs @@ -11,7 +11,7 @@ use ignition::{ }, }; use serde::Deserialize; -use serde_yaml::{Mapping, Sequence, Value}; +use serde_yaml::Value; use tokio::fs::{read_dir, read_to_string}; use crate::{ @@ -19,8 +19,8 @@ use crate::{ client::get_api_client, config::Config, expr::{ - ctx::{EnvAmbientOverrideBehavior, ExprEvalContext, ExprEvalContextConfig}, - eval::eval_expr, + ctx::{EnvAmbientOverrideBehavior, ExprEvalContext, ExprEvalContextConfig, LttleInfo}, + eval::{eval_expr, transform_eval_expressions_root}, }, ui::message::{message_detail, message_info, message_warn}, }; @@ -74,6 +74,9 @@ pub struct DeployArgs { pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { let api_client = get_api_client(config.try_into()?); + let me = api_client.core().me().await?; + let profile = config.current_profile.clone(); + let additional_vars = args .additional_vars .iter() @@ -87,9 +90,10 @@ pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { }) .collect(); - let context = ExprEvalContext::new(ExprEvalContextConfig { + let mut context = ExprEvalContext::new(ExprEvalContextConfig { env_file: args.env_file, var_file: args.var_file, + initial_vars: None, aditional_vars: Some(additional_vars), git_dir: std::env::current_dir()?, env_ambient_override_behavior: if args.ignore_env_ambient_override { @@ -97,6 +101,11 @@ pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { } else { EnvAmbientOverrideBehavior::Override }, + lttle_info: LttleInfo { + tenant: me.tenant, + user: me.sub, + profile: profile, + }, }) .await?; @@ -140,9 +149,9 @@ pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { let mut resources = Vec::new(); if path.is_file() { let contents = read_to_string(&path).await?; - parse_all_resources(path, &contents, &mut resources, &context).await?; + parse_all_resources(path, &contents, &mut resources, &mut context).await?; } else if path.is_dir() { - parse_all_resources_in_dir(&path, &mut resources, &context, args.recursive).await?; + parse_all_resources_in_dir(&path, &mut resources, &mut context, args.recursive).await?; } else { bail!("Invalid path: {:?}", path); } @@ -237,7 +246,7 @@ pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { fn parse_all_resources_in_dir<'a>( path: &'a PathBuf, resources: &'a mut Vec<(PathBuf, Resources)>, - context: &'a ExprEvalContext, + context: &'a mut ExprEvalContext, recursive: bool, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { @@ -254,7 +263,7 @@ fn parse_all_resources_in_dir<'a>( } let contents = read_to_string(file.path()).await?; - parse_all_resources(file.path(), &contents, resources, &context).await?; + parse_all_resources(file.path(), &contents, resources, context).await?; } if file.path().is_dir() && recursive { @@ -270,7 +279,7 @@ async fn parse_all_resources( path: PathBuf, contents: &str, resources: &mut Vec<(PathBuf, Resources)>, - expr_eval_context: &ExprEvalContext, + expr_eval_context: &mut ExprEvalContext, ) -> Result<()> { let de = serde_yaml::Deserializer::from_str(contents); for doc in de { @@ -284,100 +293,14 @@ async fn parse_all_resources( fn eval_and_validate_resource( resource_src: &Value, - context: &ExprEvalContext, + context: &mut ExprEvalContext, ) -> Result { - fn transform_eval_expressions(value: &Value, context: &ExprEvalContext) -> Result { - if let Some(str) = value.as_str() { - let new_value = parse_and_eval_expr(str, context)?; - return Ok(new_value.unwrap_or(value.clone())); - } - - if let Some(map) = value.as_mapping() { - let mut new_map = Mapping::new(); - for (key, value) in map { - new_map.insert(key.clone(), transform_eval_expressions(value, context)?); - } - Ok(Value::Mapping(new_map)) - } else if let Some(seq) = value.as_sequence() { - let mut new_seq = Sequence::new(); - for value in seq { - new_seq.push(transform_eval_expressions(value, context)?); - } - Ok(Value::Sequence(new_seq)) - } else { - Ok(value.clone()) - } - } - - let value = transform_eval_expressions(resource_src, context)?; + let value = transform_eval_expressions_root(resource_src, context)?; let resource: Resources = serde_yaml::with::singleton_map_recursive::deserialize(value)?; Ok(resource) } -fn parse_and_eval_expr(expr: &str, context: &ExprEvalContext) -> Result> { - // either - // 1. it starts with ${{ and ends with }} => we eval the expression and return the result as a value - // 2. or it contains ${{ and }} => we eval the expression/s, convert the result to a string and replace in the original string - // 3. or is just a regular string => we return the original string - - let expr = expr.trim(); - - let expr_start_marker_count = expr.matches("${{").count(); - let expr_end_marker_count = expr.matches("}}").count(); - - if expr_start_marker_count == 0 && expr_end_marker_count == 0 { - return Ok(None); - } - - if expr.starts_with("${{") - && expr.ends_with("}}") - && expr_start_marker_count == 1 - && expr_end_marker_count == 1 - { - let expr = expr - .trim_start_matches("${{") - .trim_end_matches("}}") - .trim() - .to_string(); - - return eval_expr(&expr, context).map(|v| Some(v)); - } - - // loop should be find, split, eval, replace, repeat\ - let mut output = expr.to_string(); - loop { - let start = output.find("${{").unwrap_or(0); - let end = output.find("}}").unwrap_or(0); - - if start == 0 && end == 0 { - break; - } - - let expr = output[start + 3..end - 1].trim(); - - if expr.is_empty() { - break; - } - - let value = eval_expr(&expr, context)?; - let value_str = match value { - Value::Bool(b) => b.to_string(), - Value::Number(n) => n.to_string(), - Value::String(s) => s.to_string(), - Value::Null => "null".to_string(), - _ => bail!( - "Invalid value '{:?}' returned by expression '{}'", - value, - expr - ), - }; - output = output[..start].to_string() + &value_str + &output[end + 2..]; - } - - return Ok(Some(Value::String(output))); -} - async fn deploy_machine(_config: &Config, api_client: &ApiClient, machine: Machine) -> Result<()> { let metadata = machine.metadata(); api_client.machine().apply(machine).await?; diff --git a/src/cli/expr/ctx.rs b/src/cli/expr/ctx.rs index 454ebb8..5a5ccea 100644 --- a/src/cli/expr/ctx.rs +++ b/src/cli/expr/ctx.rs @@ -6,18 +6,24 @@ use serde::{Deserialize, Serialize}; use serde_yaml::Value; use tokio::fs::read_to_string; -use crate::{expr::std_lib, ui::message::message_warn}; +use crate::{ + expr::{eval::transform_eval_expressions, std_lib}, + ui::message::message_warn, +}; +#[derive(Clone)] pub struct ExprEvalContextConfig { pub env_file: Option, pub var_file: Option, // needs parsing + pub initial_vars: Option>, pub aditional_vars: Option>, pub git_dir: PathBuf, pub env_ambient_override_behavior: EnvAmbientOverrideBehavior, + pub lttle_info: LttleInfo, } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone)] pub enum EnvAmbientOverrideBehavior { /// If the environment variable is already set, it will be overridden by the value from the file Override, @@ -27,29 +33,38 @@ pub enum EnvAmbientOverrideBehavior { #[derive(Serialize, Deserialize, Clone)] pub struct ExprEvalContext { - env: BTreeMap, - var: BTreeMap, - git: Option, + pub env: BTreeMap, + pub var: BTreeMap, + pub git: Option, + pub lttle: LttleInfo, + pub namespace: Option, } #[derive(Serialize, Deserialize, Clone)] pub struct GitInfo { - branch: Option, + pub branch: Option, #[serde(rename = "commitSha")] - commit_sha: String, // 8 chars + pub commit_sha: String, // 8 chars #[serde(rename = "commitMessage")] - commit_message: String, + pub commit_message: String, #[serde(rename = "tag")] - tag: Option, + pub tag: Option, #[serde(rename = "latestTag")] - latest_tag: Option, + pub latest_tag: Option, #[serde(rename = "ref")] - r#ref: String, + pub r#ref: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct LttleInfo { + pub tenant: String, + pub user: String, + pub profile: String, } impl Debug for GitInfo { @@ -78,7 +93,7 @@ impl Debug for ExprEvalContext { impl ExprEvalContext { pub async fn new(config: ExprEvalContextConfig) -> Result { let mut envs = BTreeMap::new(); - if let Some(env_file) = config.env_file { + if let Some(env_file) = config.env_file.clone() { let mut iter = dotenvy::from_filename_iter(env_file)?; while let Some(line) = iter.next() { let Ok((key, val)) = line else { @@ -102,10 +117,20 @@ impl ExprEvalContext { envs.insert(key, val); } - let mut vars: BTreeMap = BTreeMap::new(); - if let Some(var_file) = config.var_file { + let mut vars: BTreeMap = config.initial_vars.clone().unwrap_or_default(); + if let Some(var_file) = config.var_file.clone() { let contents = read_to_string(var_file).await?; - vars = serde_yaml::from_str(&contents)?; + let value = serde_yaml::from_str(&contents)?; + + let mut vars_eval_ctx_config = config.clone(); + vars_eval_ctx_config.var_file = None; + vars_eval_ctx_config.initial_vars = + Some(extract_top_level_vars_without_expressions(&value)?); + + let vars_eval_ctx = Box::pin(ExprEvalContext::new(vars_eval_ctx_config)).await?; + + let value = transform_eval_expressions(&value, &vars_eval_ctx)?; + vars = serde_yaml::from_value(value)?; } for (key, val) in config.aditional_vars.unwrap_or_default() { @@ -132,6 +157,8 @@ impl ExprEvalContext { env: envs, var: vars, git: git_info, + lttle: config.lttle_info, + namespace: None, }) } } @@ -242,6 +269,8 @@ impl TryFrom<&ExprEvalContext> for cel::Context<'_> { ctx.add_variable("var", context.var.clone())?; ctx.add_variable("env", context.env.clone())?; ctx.add_variable("git", context.git.clone())?; + ctx.add_variable("lttle", context.lttle.clone())?; + ctx.add_variable("namespace", context.namespace.clone())?; // custom string functions ctx.add_function("last", std_lib::str::last); @@ -265,3 +294,24 @@ impl TryFrom<&ExprEvalContext> for cel::Context<'_> { Ok(ctx) } } + +fn extract_top_level_vars_without_expressions(value: &Value) -> Result> { + let mut vars = BTreeMap::new(); + if let Some(map) = value.as_mapping() { + for (key, value) in map { + let Value::String(key_str) = key else { + continue; + }; + + if let Some(str) = value.as_str() { + if str.contains("${{") && str.contains("}}") { + continue; + } + } + + vars.insert(key_str.clone(), value.clone()); + } + } + + Ok(vars) +} diff --git a/src/cli/expr/eval.rs b/src/cli/expr/eval.rs index d9d4ea1..df4fd6f 100644 --- a/src/cli/expr/eval.rs +++ b/src/cli/expr/eval.rs @@ -1,5 +1,6 @@ use anyhow::{Result, bail}; -use serde_yaml::Value; +use ignition::constants::DEFAULT_NAMESPACE; +use serde_yaml::{Mapping, Sequence, Value}; use crate::expr::ctx::ExprEvalContext; @@ -18,3 +19,127 @@ pub fn eval_expr(expr: &str, context: &ExprEvalContext) -> Result { return Ok(value); } + +pub fn transform_eval_expressions_root( + value: &Value, + context: &mut ExprEvalContext, +) -> Result { + context.namespace = None; + + if let Value::Mapping(map) = value { + if let Some(namespace) = extract_namespace_from_map(&map, context)? { + context.namespace = Some(namespace); + } else { + context.namespace = Some(DEFAULT_NAMESPACE.to_string()); + } + } + + transform_eval_expressions(value, context) +} + +fn extract_namespace_from_map(map: &Mapping, context: &ExprEvalContext) -> Result> { + if map.len() != 1 { + return Ok(None); + } + + let single_key = map.keys().next().unwrap(); + let Some(resource) = map.get(single_key).unwrap().as_mapping().cloned() else { + return Ok(None); + }; + + let Some(Value::String(namespace)) = resource.get("namespace") else { + return Ok(None); + }; + + let Some(Value::String(namespace)) = parse_and_eval_expr(namespace, context)? else { + bail!("Failed to evaluate namespace expr"); + }; + + Ok(Some(namespace)) +} + +pub fn transform_eval_expressions(value: &Value, context: &ExprEvalContext) -> Result { + if let Some(str) = value.as_str() { + let new_value = parse_and_eval_expr(str, context)?; + return Ok(new_value.unwrap_or(value.clone())); + } + + if let Some(map) = value.as_mapping() { + let mut new_map = Mapping::new(); + for (key, value) in map { + new_map.insert(key.clone(), transform_eval_expressions(value, context)?); + } + Ok(Value::Mapping(new_map)) + } else if let Some(seq) = value.as_sequence() { + let mut new_seq = Sequence::new(); + for value in seq { + new_seq.push(transform_eval_expressions(value, context)?); + } + Ok(Value::Sequence(new_seq)) + } else { + Ok(value.clone()) + } +} + +fn parse_and_eval_expr(expr: &str, context: &ExprEvalContext) -> Result> { + // either + // 1. it starts with ${{ and ends with }} => we eval the expression and return the result as a value + // 2. or it contains ${{ and }} => we eval the expression/s, convert the result to a string and replace in the original string + // 3. or is just a regular string => we return the original string + + let expr = expr.trim(); + + let expr_start_marker_count = expr.matches("${{").count(); + let expr_end_marker_count = expr.matches("}}").count(); + + if expr_start_marker_count == 0 && expr_end_marker_count == 0 { + return Ok(None); + } + + if expr.starts_with("${{") + && expr.ends_with("}}") + && expr_start_marker_count == 1 + && expr_end_marker_count == 1 + { + let expr = expr + .trim_start_matches("${{") + .trim_end_matches("}}") + .trim() + .to_string(); + + return eval_expr(&expr, context).map(|v| Some(v)); + } + + // loop should be find, split, eval, replace, repeat\ + let mut output = expr.to_string(); + loop { + let start = output.find("${{").unwrap_or(0); + let end = output.find("}}").unwrap_or(0); + + if start == 0 && end == 0 { + break; + } + + let expr = output[start + 3..end - 1].trim(); + + if expr.is_empty() { + break; + } + + let value = eval_expr(&expr, context)?; + let value_str = match value { + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.to_string(), + Value::Null => "null".to_string(), + _ => bail!( + "Invalid value '{:?}' returned by expression '{}'", + value, + expr + ), + }; + output = output[..start].to_string() + &value_str + &output[end + 2..]; + } + + return Ok(Some(Value::String(output))); +} From 4a69b911a4ed195f30daa671913d7b06897d988d Mon Sep 17 00:00:00 2001 From: Laurentiu Ciobanu Date: Fri, 12 Sep 2025 14:41:40 +0000 Subject: [PATCH 2/2] dry run mode --- src/cli/cmd/deploy.rs | 128 +++++++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/src/cli/cmd/deploy.rs b/src/cli/cmd/deploy.rs index 35a111c..ca8d906 100644 --- a/src/cli/cmd/deploy.rs +++ b/src/cli/cmd/deploy.rs @@ -1,16 +1,22 @@ use std::path::PathBuf; +use ansi_term::{Color, Style}; use anyhow::{Result, bail}; use clap::{ArgAction, Args}; use ignition::{ api_client::ApiClient, resource_index::Resources, resources::{ - ProvideMetadata, app::App, certificate::Certificate, machine::Machine, metadata::Namespace, - service::Service, volume::Volume, + ProvideMetadata, + app::App, + certificate::Certificate, + machine::Machine, + metadata::{Metadata, Namespace}, + service::Service, + volume::Volume, }, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_yaml::Value; use tokio::fs::{read_dir, read_to_string}; @@ -59,6 +65,10 @@ pub struct DeployArgs { #[arg(long = "debug-context")] debug_context: bool, + /// Print the changes that would be committed without applying them + #[arg(long = "dry-run")] + dry_run: bool, + /// Dump the context to stdout as JSON #[arg(long = "dump-context-json")] dump_context_json: bool, @@ -146,6 +156,10 @@ pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { bail!("Path does not exist: {:?}", path); } + if args.dry_run { + message_info("Dry run mode enabled. No changes will be committed."); + } + let mut resources = Vec::new(); if path.is_file() { let contents = read_to_string(&path).await?; @@ -212,32 +226,68 @@ pub async fn run_deploy(config: &Config, args: DeployArgs) -> Result<()> { } for (_path, resource) in resources { - if let Ok(certificate) = resource.clone().try_into() { - deploy_certificate(config, &api_client, certificate).await?; - continue; - } - - if let Ok(machine) = resource.clone().try_into() { - deploy_machine(config, &api_client, machine).await?; - continue; - } - - if let Ok(service) = resource.clone().try_into() { - deploy_service(config, &api_client, service).await?; - continue; - } - - if let Ok(volume) = resource.clone().try_into() { - deploy_volume(config, &api_client, volume).await?; - continue; - } - - if let Ok(app) = resource.clone().try_into() { - deploy_app(config, &api_client, app).await?; - continue; - } + match resource { + Resources::Certificate(certificate) | Resources::CertificateV1(certificate) => { + if args.dry_run { + deploy_dry_run::( + config, + &api_client, + "certificate", + certificate.metadata(), + certificate.into(), + )?; + continue; + } + deploy_certificate(config, &api_client, certificate.into()).await?; + } + Resources::App(app) | Resources::AppV1(app) => { + if args.dry_run { + deploy_dry_run::(config, &api_client, "app", app.metadata(), app.into())?; + continue; + } + deploy_app(config, &api_client, app.into()).await?; + } + Resources::Machine(machine) | Resources::MachineV1(machine) => { + if args.dry_run { + deploy_dry_run::( + config, + &api_client, + "machine", + machine.metadata(), + machine.into(), + )?; + continue; + } - unreachable!("Unknown resource type: {:?}", resource); + deploy_machine(config, &api_client, machine.into()).await?; + } + Resources::Service(service) | Resources::ServiceV1(service) => { + if args.dry_run { + deploy_dry_run::( + config, + &api_client, + "service", + service.metadata(), + service.into(), + )?; + continue; + } + deploy_service(config, &api_client, service.into()).await?; + } + Resources::Volume(volume) | Resources::VolumeV1(volume) => { + if args.dry_run { + deploy_dry_run::( + config, + &api_client, + "volume", + volume.metadata(), + volume.into(), + )?; + continue; + } + deploy_volume(config, &api_client, volume.into()).await?; + } + }; } Ok(()) @@ -404,3 +454,25 @@ async fn deploy_app(_config: &Config, api_client: &ApiClient, app: App) -> Resul Ok(()) } + +fn deploy_dry_run( + _config: &Config, + _api_client: &ApiClient, + resource_type_name: &'static str, + metadata: Metadata, + resource: T, +) -> Result<()> { + let resource = serde_yaml::to_string(&resource)?; + + let type_style = Style::new().fg(Color::Yellow); + let metadata_style = Style::new().bold().fg(Color::Blue); + + eprintln!( + "→ {} {} as: \n{}", + type_style.paint(resource_type_name), + metadata_style.paint(metadata.to_string()), + resource + ); + + Ok(()) +}