diff --git a/mantle/Cargo.lock b/mantle/Cargo.lock index 1f403fb..5711dc2 100644 --- a/mantle/Cargo.lock +++ b/mantle/Cargo.lock @@ -1338,9 +1338,10 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mantle" -version = "0.11.11" +version = "0.11.13" dependencies = [ "clap", + "difference", "dotenv", "env_logger", "integration_executor", @@ -2066,7 +2067,7 @@ dependencies = [ [[package]] name = "rbx_api" -version = "0.4.8" +version = "0.4.9" dependencies = [ "base64 0.13.1", "log", @@ -2140,7 +2141,7 @@ dependencies = [ [[package]] name = "rbx_mantle" -version = "0.11.11" +version = "0.11.13" dependencies = [ "async-trait", "chrono", diff --git a/mantle/logger/src/lib.rs b/mantle/logger/src/lib.rs index b2774f4..27d5e2f 100644 --- a/mantle/logger/src/lib.rs +++ b/mantle/logger/src/lib.rs @@ -40,7 +40,7 @@ where S: Display, { let line_prefix = get_line_prefix(); - println!("{}", with_prefix(&message, line_prefix)); + eprintln!("{}", with_prefix(&message, line_prefix)); } pub fn start_action(title: S) diff --git a/mantle/mantle/Cargo.toml b/mantle/mantle/Cargo.toml index dff4051..6667533 100644 --- a/mantle/mantle/Cargo.toml +++ b/mantle/mantle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mantle" -version = "0.11.11" +version = "0.11.13" edition = "2021" [dependencies] @@ -14,6 +14,7 @@ serde_json = { version = "1.0.59" } serde_yaml = { version = "0.8" } serde = { version = "1.0", features = ["derive"] } clap = "2.33.0" +difference = "2.0.0" tokio = { version = "1", features = ["full"] } yansi = "0.5.0" log = "0.4.14" diff --git a/mantle/mantle/src/cli.rs b/mantle/mantle/src/cli.rs index 8196dcb..c25aa6f 100644 --- a/mantle/mantle/src/cli.rs +++ b/mantle/mantle/src/cli.rs @@ -26,6 +26,37 @@ fn get_app() -> App<'static, 'static> { Arg::with_name("allow_purchases") .long("allow-purchases") .help("Gives Mantle permission to make purchases with Robux.")) + ) + .subcommand( + SubCommand::with_name("diff") + .about("Prints the diff between the current state file and project configuration.") + .arg( + Arg::with_name("PROJECT") + .index(1) + .help("The Mantle project: either the path to a directory containing a 'mantle.yml' file or the path to a configuration file. Defaults to the current directory.") + .takes_value(true)) + .arg( + Arg::with_name("environment") + .long("environment") + .short("e") + .help("The label of the environment to deploy to. If not specified, attempts to match the current git branch to each environment's `branches` property.") + .value_name("ENVIRONMENT") + .takes_value(true)) + .arg( + Arg::with_name("output") + .long("output") + .short("o") + .help("A file path to print the diff to, if a format is provided") + .value_name("FILE") + .takes_value(true)) + .arg( + Arg::with_name("format") + .long("format") + .short("f") + .help("The format to print the diff in") + .value_name("FORMAT") + .takes_value(true) + .possible_values(&["json","yaml"])) ) .subcommand( SubCommand::with_name("destroy") @@ -147,6 +178,15 @@ pub async fn run_with(args: Vec) -> i32 { ) .await } + ("diff", Some(diff_matches)) => { + commands::diff::run( + diff_matches.value_of("PROJECT"), + diff_matches.value_of("environment"), + diff_matches.value_of("output"), + diff_matches.value_of("format"), + ) + .await + } ("destroy", Some(destroy_matches)) => { commands::destroy::run( destroy_matches.value_of("PROJECT"), diff --git a/mantle/mantle/src/commands/diff.rs b/mantle/mantle/src/commands/diff.rs new file mode 100644 index 0000000..53648a4 --- /dev/null +++ b/mantle/mantle/src/commands/diff.rs @@ -0,0 +1,142 @@ +use std::{fs, str}; + +use difference::Changeset; +use yansi::Paint; + +use rbx_mantle::{ + config::load_project_config, + project::{load_project, Project}, + resource_graph::ResourceGraphDiff, + state::get_desired_graph, +}; + +fn get_changeset(previous_hash: &str, new_hash: &str) -> Changeset { + Changeset::new(previous_hash, new_hash, "\n") +} + +fn print_diff(diff: ResourceGraphDiff) { + for (resource_id, r) in diff.removals.into_iter() { + logger::start_action(format!("{} Removed {}:", Paint::red("-"), resource_id)); + logger::log("Inputs:"); + logger::log_changeset(get_changeset(&r.previous_inputs_hash, "")); + logger::end_action_without_message(); + } + + for (resource_id, r) in diff.additions.into_iter() { + logger::start_action(format!("{} Added {}:", Paint::green("+"), resource_id)); + logger::log("Inputs:"); + logger::log_changeset(get_changeset("", &r.current_inputs_hash)); + logger::end_action_without_message(); + } + + for (resource_id, r) in diff.changes.into_iter() { + logger::start_action(format!("{} Changed {}:", Paint::yellow("~"), resource_id)); + logger::log("Inputs:"); + logger::log_changeset(get_changeset( + &r.previous_inputs_hash, + &r.current_inputs_hash, + )); + logger::end_action_without_message(); + } + + for (resource_id, r) in diff.dependency_changes.into_iter() { + logger::start_action(format!( + "{} Dependency Changed {}:", + Paint::new("○").dimmed(), + resource_id + )); + logger::log("Changed dependencies:"); + for dependency_id in r.changed_dependencies.into_iter() { + logger::log(format!( + " {} {}", + Paint::new("-").dimmed(), + Paint::yellow(dependency_id) + )) + } + logger::end_action_without_message(); + } +} + +pub async fn run( + project: Option<&str>, + environment: Option<&str>, + output: Option<&str>, + format: Option<&str>, +) -> i32 { + logger::start_action("Loading project:"); + let (project_path, config) = match load_project_config(project) { + Ok(v) => v, + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; + let Project { + current_graph, + target_config, + owner_config, + .. + } = match load_project(project_path.clone(), config, environment).await { + Ok(Some(v)) => v, + Ok(None) => { + logger::end_action("No diff available"); + return 0; + } + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; + let mut next_graph = + match get_desired_graph(project_path.as_path(), &target_config, &owner_config) { + Ok(v) => v, + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; + logger::end_action("Succeeded"); + + logger::start_action("Diffing resource graphs:"); + + let diff = next_graph.diff(¤t_graph); + + match diff { + Ok(diff) => { + let outputs_string = format.map(|format| match format { + "json" => serde_json::to_string_pretty(&diff) + .map(|x| x + "\n") + .map_err(|e| e.to_string()), + "yaml" => serde_yaml::to_string(&diff).map_err(|e| e.to_string()), + _ => Err(format!("Unknown format: {}", format)), + }); + + print_diff(diff); + logger::end_action("Succeeded"); + + if let Some(outputs_string) = outputs_string { + if let Ok(outputs_string) = outputs_string { + if let Some(output) = output { + if let Err(e) = fs::write(output, outputs_string).map_err(|e| { + format!("Unable to write outputs file: {}\n\t{}", output, e) + }) { + logger::log(Paint::red(e)); + return 1; + } + } else { + print!("{}", outputs_string); + } + } else { + logger::log(Paint::red("Failed to serialize outputs")); + return 1; + } + } + + 0 + } + Err(e) => { + logger::end_action(Paint::red(e)); + 1 + } + } +} diff --git a/mantle/mantle/src/commands/mod.rs b/mantle/mantle/src/commands/mod.rs index c1fffd0..adb240d 100644 --- a/mantle/mantle/src/commands/mod.rs +++ b/mantle/mantle/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod deploy; pub mod destroy; +pub mod diff; pub mod download; pub mod import; pub mod outputs; diff --git a/mantle/mantle/src/commands/outputs.rs b/mantle/mantle/src/commands/outputs.rs index bca1bff..2b4c077 100644 --- a/mantle/mantle/src/commands/outputs.rs +++ b/mantle/mantle/src/commands/outputs.rs @@ -42,7 +42,9 @@ pub async fn run( .collect::>(); let outputs_string = match match format { - "json" => serde_json::to_string_pretty(&outputs_map).map_err(|e| e.to_string()), + "json" => serde_json::to_string_pretty(&outputs_map) + .map(|x| x + "\n") + .map_err(|e| e.to_string()), "yaml" => serde_yaml::to_string(&outputs_map).map_err(|e| e.to_string()), _ => Err(format!("Unknown format: {}", format)), } { @@ -62,7 +64,7 @@ pub async fn run( return 1; } } else { - logger::log(outputs_string); + print!("{}", outputs_string); } 0 diff --git a/mantle/rbx_api/Cargo.toml b/mantle/rbx_api/Cargo.toml index b4e963e..c645f24 100644 --- a/mantle/rbx_api/Cargo.toml +++ b/mantle/rbx_api/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rbx_api" description = "Make requests to Roblox's web APIs" -version = "0.4.8" +version = "0.4.9" edition = "2021" homepage = "https://github.com/blake-mealey/mantle/tree/main/rbx_api" repository = "https://github.com/blake-mealey/mantle" diff --git a/mantle/rbx_api/src/game_passes/mod.rs b/mantle/rbx_api/src/game_passes/mod.rs index ddadf3a..f6c8fef 100644 --- a/mantle/rbx_api/src/game_passes/mod.rs +++ b/mantle/rbx_api/src/game_passes/mod.rs @@ -21,10 +21,13 @@ impl RobloxApi { experience_id: AssetId, page_cursor: Option, ) -> RobloxApiResult { - let mut req = self.client.get(format!( - "https://games.roblox.com/v1/games/{}/game-passes", - experience_id - )); + let mut req = self + .client + .get(format!( + "https://games.roblox.com/v1/games/{}/game-passes", + experience_id + )) + .query(&[("limit", 100.to_string())]); if let Some(page_cursor) = page_cursor { req = req.query(&[("cursor", &page_cursor)]); } diff --git a/mantle/rbx_mantle/Cargo.toml b/mantle/rbx_mantle/Cargo.toml index a2cc35f..f442825 100644 --- a/mantle/rbx_mantle/Cargo.toml +++ b/mantle/rbx_mantle/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rbx_mantle" description = "Infra-as-code for Roblox" -version = "0.11.11" +version = "0.11.13" homepage = "https://mantledeploy.vercel.app" repository = "https://github.com/blake-mealey/mantle" authors = ["Blake Mealey "] diff --git a/mantle/rbx_mantle/src/resource_graph.rs b/mantle/rbx_mantle/src/resource_graph.rs index bdda778..abaa4ce 100644 --- a/mantle/rbx_mantle/src/resource_graph.rs +++ b/mantle/rbx_mantle/src/resource_graph.rs @@ -521,7 +521,7 @@ where continue; } - let operation_result = self + let operation_result: OperationResult = self .evaluate_delete(previous_graph, manager, resource_id) .await; self.handle_operation_result( @@ -556,4 +556,127 @@ where Ok(results) } } + + pub fn diff( + &mut self, + previous_graph: &ResourceGraph, + ) -> Result { + let mut diff = ResourceGraphDiff { + removals: BTreeMap::new(), + additions: BTreeMap::new(), + changes: BTreeMap::new(), + dependency_changes: BTreeMap::new(), + }; + + // Iterate over previous resources in reverse order so that leaf resources are removed first + let mut previous_resource_order = previous_graph.get_topological_order()?; + previous_resource_order.reverse(); + + for resource_id in previous_resource_order.iter() { + if self.resources.get(resource_id).is_some() { + continue; + } + + diff.removals.insert( + resource_id.to_owned(), + ResourceRemoval { + previous_inputs_hash: previous_graph + .resources + .get(resource_id) + .unwrap() + .get_inputs_hash(), + previous_outputs_hash: previous_graph + .resources + .get(resource_id) + .unwrap() + .get_outputs_hash(), + }, + ); + } + + let resource_order = self.get_topological_order()?; + for resource_id in resource_order.iter() { + let resource = self.resources.get(resource_id).unwrap(); + let inputs_hash = resource.get_inputs_hash(); + + let previous_resource = previous_graph.resources.get(resource_id); + + if let Some(previous_resource) = previous_resource { + let previous_hash = previous_resource.get_inputs_hash(); + if previous_hash != inputs_hash { + diff.changes.insert( + resource_id.to_owned(), + ResourceChange { + previous_inputs_hash: previous_hash, + previous_outputs_hash: previous_resource.get_outputs_hash(), + current_inputs_hash: inputs_hash, + }, + ); + } else { + let dependencies = resource.get_dependencies(); + #[allow(clippy::iter_overeager_cloned)] + let changed_dependencies: Vec<_> = dependencies + .iter() + .cloned() + .filter(|x| diff.additions.contains_key(x) || diff.changes.contains_key(x)) + .collect(); + + if !changed_dependencies.is_empty() { + diff.dependency_changes.insert( + resource_id.to_owned(), + ResourceDependencyChange { + previous_inputs_hash: previous_hash, + previous_outputs_hash: previous_resource.get_outputs_hash(), + current_inputs_hash: inputs_hash, + changed_dependencies, + }, + ); + } + } + } else { + diff.additions.insert( + resource_id.to_owned(), + ResourceAddition { + current_inputs_hash: inputs_hash, + }, + ); + } + } + + Ok(diff) + } +} + +#[derive(Serialize)] +pub struct ResourceGraphDiff { + pub removals: BTreeMap, + pub additions: BTreeMap, + pub changes: BTreeMap, + pub dependency_changes: BTreeMap, +} + +#[derive(Serialize)] +pub struct ResourceRemoval { + pub previous_inputs_hash: String, + pub previous_outputs_hash: String, +} + +#[derive(Serialize)] +pub struct ResourceAddition { + pub current_inputs_hash: String, +} + +#[derive(Serialize)] +pub struct ResourceChange { + pub previous_inputs_hash: String, + pub previous_outputs_hash: String, + pub current_inputs_hash: String, +} + +#[derive(Serialize)] +pub struct ResourceDependencyChange { + pub previous_inputs_hash: String, + pub previous_outputs_hash: String, + pub current_inputs_hash: String, + pub changed_dependencies: Vec, }