From 8a83c383e6c368c01831bae3b9c9611e8dc17b8c Mon Sep 17 00:00:00 2001 From: Fabio Falcinelli Date: Tue, 24 Mar 2026 09:56:03 +0100 Subject: [PATCH 1/3] Refactoring and optimizations to improve code coverage --- .gitignore | 2 + src/apply.rs | 961 ------------------------- src/apply/actions.rs | 284 ++++++++ src/apply/clients.rs | 242 +++++++ src/apply/components.rs | 262 +++++++ src/apply/flows.rs | 232 ++++++ src/apply/groups.rs | 214 ++++++ src/apply/idps.rs | 97 +++ src/apply/mod.rs | 219 ++++++ src/apply/realm.rs | 43 ++ src/apply/roles.rs | 221 ++++++ src/apply/scopes.rs | 223 ++++++ src/apply/users.rs | 218 ++++++ src/clean.rs | 50 +- src/cli.rs | 1161 ------------------------------- src/cli/client.rs | 178 +++++ src/cli/group.rs | 78 +++ src/cli/idp.rs | 98 +++ src/cli/keys.rs | 266 +++++++ src/cli/mod.rs | 126 ++++ src/cli/role.rs | 146 ++++ src/cli/user.rs | 394 +++++++++++ src/inspect.rs | 807 +++++++++++---------- src/lib.rs | 125 ++++ src/main.rs | 130 +--- src/models.rs | 14 +- src/plan.rs | 1119 ----------------------------- src/plan/actions.rs | 110 +++ src/plan/clients.rs | 107 +++ src/plan/components.rs | 198 ++++++ src/plan/flows.rs | 110 +++ src/plan/groups.rs | 107 +++ src/plan/idps.rs | 110 +++ src/plan/mod.rs | 264 +++++++ src/plan/realm.rs | 64 ++ src/plan/roles.rs | 107 +++ src/plan/scopes.rs | 110 +++ src/plan/users.rs | 105 +++ src/validate.rs | 77 +- tests/common/mod.rs | 8 +- tests/coverage_test.rs | 36 +- tests/lib_test.rs | 40 ++ tests/models_coverage_test.rs | 14 +- tests/plan_components_test.rs | 64 ++ tests/plan_coverage_test.rs | 128 ++++ tests/plan_extended_test.rs | 140 ++++ tests/run_app_test.rs | 169 +++++ tests/ultimate_coverage_test.rs | 1 - tests/validate_test.rs | 108 +-- 49 files changed, 6233 insertions(+), 3854 deletions(-) delete mode 100644 src/apply.rs create mode 100644 src/apply/actions.rs create mode 100644 src/apply/clients.rs create mode 100644 src/apply/components.rs create mode 100644 src/apply/flows.rs create mode 100644 src/apply/groups.rs create mode 100644 src/apply/idps.rs create mode 100644 src/apply/mod.rs create mode 100644 src/apply/realm.rs create mode 100644 src/apply/roles.rs create mode 100644 src/apply/scopes.rs create mode 100644 src/apply/users.rs delete mode 100644 src/cli.rs create mode 100644 src/cli/client.rs create mode 100644 src/cli/group.rs create mode 100644 src/cli/idp.rs create mode 100644 src/cli/keys.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/role.rs create mode 100644 src/cli/user.rs delete mode 100644 src/plan.rs create mode 100644 src/plan/actions.rs create mode 100644 src/plan/clients.rs create mode 100644 src/plan/components.rs create mode 100644 src/plan/flows.rs create mode 100644 src/plan/groups.rs create mode 100644 src/plan/idps.rs create mode 100644 src/plan/mod.rs create mode 100644 src/plan/realm.rs create mode 100644 src/plan/roles.rs create mode 100644 src/plan/scopes.rs create mode 100644 src/plan/users.rs create mode 100644 tests/lib_test.rs create mode 100644 tests/plan_components_test.rs create mode 100644 tests/plan_coverage_test.rs create mode 100644 tests/plan_extended_test.rs create mode 100644 tests/run_app_test.rs diff --git a/.gitignore b/.gitignore index 00a9d52..81fe437 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ workspace/.secrets *.profraw lcov.info new-component.yaml +coverage.txt +cobertura.xml diff --git a/src/apply.rs b/src/apply.rs deleted file mode 100644 index bf3e08d..0000000 --- a/src/apply.rs +++ /dev/null @@ -1,961 +0,0 @@ -use crate::client::KeycloakClient; -use crate::models::{ - AuthenticationFlowRepresentation, ClientRepresentation, ClientScopeRepresentation, - ComponentRepresentation, GroupRepresentation, IdentityProviderRepresentation, KeycloakResource, - RealmRepresentation, RequiredActionProviderRepresentation, RoleRepresentation, - UserRepresentation, -}; -use crate::utils::secrets::substitute_secrets; -use anyhow::{Context, Result}; -use console::{Emoji, style}; -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::fs as async_fs; -use tokio::task::JoinSet; - -static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); -static SUCCESS_CREATE: Emoji<'_, '_> = Emoji("✨ ", "+ "); -static SUCCESS_UPDATE: Emoji<'_, '_> = Emoji("🔄 ", "~ "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); - -pub async fn run( - client: &KeycloakClient, - workspace_dir: PathBuf, - realms_to_apply: &[String], - yes: bool, -) -> Result<()> { - if !workspace_dir.exists() { - anyhow::bail!("Input directory {:?} does not exist", workspace_dir); - } - - // Load .secrets from input directory if it exists - let env_path = workspace_dir.join(".secrets"); - if env_path.exists() { - dotenvy::from_path(&env_path).ok(); - } - - let env_vars = Arc::new(std::env::vars().collect::>()); - - // Check for .kcdplan - let plan_path = workspace_dir.join(".kcdplan"); - let planned_files = if plan_path.exists() { - let content = async_fs::read_to_string(&plan_path).await?; - let items: Vec = serde_json::from_str(&content)?; - if items.is_empty() { - if !yes { - let proceed = - dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .with_prompt( - "No planned changes found. Send everything to Keycloak anyway?", - ) - .default(false) - .interact()?; - if !proceed { - println!("Aborted."); - return Ok(()); - } - } - Arc::new(None) - } else { - let hashset: HashSet = items.into_iter().collect(); - Arc::new(Some(hashset)) - } - } else { - if !yes { - let proceed = - dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .with_prompt("No planned changes found. Send everything to Keycloak anyway?") - .default(false) - .interact()?; - if !proceed { - println!("Aborted."); - return Ok(()); - } - } - Arc::new(None) - }; - - let realms = if realms_to_apply.is_empty() { - let mut dirs = Vec::new(); - let mut entries = async_fs::read_dir(&workspace_dir).await?; - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await?.is_dir() { - dirs.push(entry.file_name().to_string_lossy().to_string()); - } - } - dirs - } else { - realms_to_apply.to_vec() - }; - - if realms.is_empty() { - println!( - "{} {}", - WARN, - style(format!("No realms found to apply in {:?}", workspace_dir)).yellow() - ); - return Ok(()); - } - - for realm_name in realms { - println!( - "\n{} {}", - ACTION, - style(format!("Applying realm: {}", realm_name)) - .cyan() - .bold() - ); - let mut realm_client = client.clone(); - realm_client.set_target_realm(realm_name.clone()); - let realm_dir = workspace_dir.join(&realm_name); - apply_single_realm( - &realm_client, - realm_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - } - - // Success - remove plan - if plan_path.exists() { - let _ = async_fs::remove_file(plan_path).await; - } - - Ok(()) -} - -async fn apply_single_realm( - client: &KeycloakClient, - workspace_dir: PathBuf, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - apply_realm( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_roles( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_identity_providers( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_clients( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_client_scopes( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_groups( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_users( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_authentication_flows( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_required_actions( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_components_or_keys( - client, - &workspace_dir, - "components", - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_components_or_keys( - client, - &workspace_dir, - "keys", - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - - Ok(()) -} - -async fn apply_realm( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 1. Apply Realm - let realm_path = workspace_dir.join("realm.yaml"); - if let Some(plan) = &*planned_files - && !plan.contains(&realm_path) - { - return Ok(()); - } - if async_fs::try_exists(&realm_path).await? { - let content = async_fs::read_to_string(&realm_path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", realm_path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let realm_rep: RealmRepresentation = serde_json::from_value(val)?; - client - .update_realm(&realm_rep) - .await - .context("Failed to update realm")?; - println!( - " {} {}", - SUCCESS_UPDATE, - style("Updated realm configuration").cyan() - ); - } - Ok(()) -} - -async fn apply_roles( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 2. Apply Roles - let roles_dir = workspace_dir.join("roles"); - if async_fs::try_exists(&roles_dir).await? { - let existing_roles = client.get_roles().await?; - let existing_roles_map: HashMap = existing_roles - .into_iter() - .filter_map(|r| { - let identity = r.get_identity(); - let id = r.id.clone(); - match (identity, id) { - (Some(identity), Some(id)) => Some((identity, id)), - _ => None, - } - }) - .collect(); - let existing_roles_map = std::sync::Arc::new(existing_roles_map); - - let mut entries = async_fs::read_dir(&roles_dir).await?; - let mut set = JoinSet::new(); - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let client = client.clone(); - let existing_roles_map = existing_roles_map.clone(); - let env_vars = Arc::clone(&env_vars); - set.spawn(async move { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut role_rep: RoleRepresentation = serde_json::from_value(val)?; - - let identity = role_rep - .get_identity() - .context(format!("Failed to get identity for role in {:?}", path))?; - - if let Some(id) = existing_roles_map.get(&identity) { - role_rep.id = Some(id.clone()); // Use remote ID - client - .update_role(id, &role_rep) - .await - .context(format!("Failed to update role {}", role_rep.get_name()))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated role {}", role_rep.get_name())).cyan() - ); - } else { - role_rep.id = None; // Don't send ID on create - client - .create_role(&role_rep) - .await - .context(format!("Failed to create role {}", role_rep.get_name()))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created role {}", role_rep.get_name())).green() - ); - } - Ok::<(), anyhow::Error>(()) - }); - } - } - while let Some(res) = set.join_next().await { - res??; - } - } - Ok(()) -} - -async fn apply_identity_providers( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 4. Apply Identity Providers - let idps_dir = workspace_dir.join("identity-providers"); - if async_fs::try_exists(&idps_dir).await? { - let existing_idps = client.get_identity_providers().await?; - let existing_idps_map: HashMap = existing_idps - .into_iter() - .filter_map(|i| i.get_identity().map(|id| (id, i))) - .collect(); - let existing_idps_map = std::sync::Arc::new(existing_idps_map); - - let mut entries = async_fs::read_dir(&idps_dir).await?; - let mut set = JoinSet::new(); - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let client = client.clone(); - let existing_idps_map = existing_idps_map.clone(); - let env_vars = Arc::clone(&env_vars); - set.spawn(async move { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut idp_rep: IdentityProviderRepresentation = serde_json::from_value(val)?; - - let identity = idp_rep - .get_identity() - .context(format!("Failed to get identity for IDP in {:?}", path))?; - - if let Some(existing) = existing_idps_map.get(&identity) { - if let Some(internal_id) = &existing.internal_id { - idp_rep.internal_id = Some(internal_id.clone()); - client - .update_identity_provider(&identity, &idp_rep) - .await - .context(format!( - "Failed to update identity provider {}", - idp_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated identity provider {}", idp_rep.get_name())) - .cyan() - ); - } - } else { - idp_rep.internal_id = None; - client - .create_identity_provider(&idp_rep) - .await - .context(format!( - "Failed to create identity provider {}", - idp_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created identity provider {}", idp_rep.get_name())) - .green() - ); - } - Ok::<(), anyhow::Error>(()) - }); - } - } - while let Some(res) = set.join_next().await { - res??; - } - } - Ok(()) -} - -async fn apply_clients( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 3. Apply Clients - let clients_dir = workspace_dir.join("clients"); - if async_fs::try_exists(&clients_dir).await? { - let existing_clients = client.get_clients().await?; - let existing_clients_map: HashMap = existing_clients - .into_iter() - .filter_map(|c| c.get_identity().map(|id| (id, c))) - .collect(); - let existing_clients_map = std::sync::Arc::new(existing_clients_map); - - let mut entries = async_fs::read_dir(&clients_dir).await?; - let mut set = JoinSet::new(); - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let client = client.clone(); - let existing_clients_map = existing_clients_map.clone(); - let env_vars = Arc::clone(&env_vars); - set.spawn(async move { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut client_rep: ClientRepresentation = serde_json::from_value(val)?; - - let identity = client_rep - .get_identity() - .context(format!("Failed to get identity for client in {:?}", path))?; - - if let Some(existing) = existing_clients_map.get(&identity) { - if let Some(id) = &existing.id { - client_rep.id = Some(id.clone()); // Use remote ID - client - .update_client(id, &client_rep) - .await - .context(format!( - "Failed to update client {}", - client_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated client {}", client_rep.get_name())).cyan() - ); - } - } else { - client_rep.id = None; // Don't send ID on create - client.create_client(&client_rep).await.context(format!( - "Failed to create client {}", - client_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created client {}", client_rep.get_name())).green() - ); - } - Ok::<(), anyhow::Error>(()) - }); - } - } - while let Some(res) = set.join_next().await { - res??; - } - } - Ok(()) -} - -async fn apply_client_scopes( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 5. Apply Client Scopes - let scopes_dir = workspace_dir.join("client-scopes"); - if async_fs::try_exists(&scopes_dir).await? { - let existing_scopes = client.get_client_scopes().await?; - let existing_scopes_map: HashMap = existing_scopes - .into_iter() - .filter_map(|s| s.get_identity().map(|id| (id, s))) - .collect(); - - let mut entries = async_fs::read_dir(&scopes_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut scope_rep: ClientScopeRepresentation = serde_json::from_value(val)?; - - let identity = scope_rep.get_identity().context(format!( - "Failed to get identity for client scope in {:?}", - path - ))?; - - if let Some(existing) = existing_scopes_map.get(&identity) { - if let Some(id) = &existing.id { - scope_rep.id = Some(id.clone()); - client - .update_client_scope(id, &scope_rep) - .await - .context(format!( - "Failed to update client scope {}", - scope_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated client scope {}", scope_rep.get_name())).cyan() - ); - } - } else { - scope_rep.id = None; - client - .create_client_scope(&scope_rep) - .await - .context(format!( - "Failed to create client scope {}", - scope_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created client scope {}", scope_rep.get_name())).green() - ); - } - } - } - } - Ok(()) -} - -async fn apply_groups( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 6. Apply Groups - let groups_dir = workspace_dir.join("groups"); - if async_fs::try_exists(&groups_dir).await? { - let existing_groups = client.get_groups().await?; - let existing_groups_map: HashMap = existing_groups - .into_iter() - .filter_map(|g| g.get_identity().map(|id| (id, g))) - .collect(); - - let mut entries = async_fs::read_dir(&groups_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut group_rep: GroupRepresentation = serde_json::from_value(val)?; - - let identity = group_rep - .get_identity() - .context(format!("Failed to get identity for group in {:?}", path))?; - - if let Some(existing) = existing_groups_map.get(&identity) { - if let Some(id) = &existing.id { - group_rep.id = Some(id.clone()); - client - .update_group(id, &group_rep) - .await - .context(format!("Failed to update group {}", group_rep.get_name()))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated group {}", group_rep.get_name())).cyan() - ); - } - } else { - group_rep.id = None; - client - .create_group(&group_rep) - .await - .context(format!("Failed to create group {}", group_rep.get_name()))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created group {}", group_rep.get_name())).green() - ); - } - } - } - } - Ok(()) -} - -async fn apply_users( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 7. Apply Users - let users_dir = workspace_dir.join("users"); - if async_fs::try_exists(&users_dir).await? { - let existing_users = client.get_users().await?; - let existing_users_map: HashMap = existing_users - .into_iter() - .filter_map(|u| u.get_identity().map(|id| (id, u))) - .collect(); - - let mut entries = async_fs::read_dir(&users_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut user_rep: UserRepresentation = serde_json::from_value(val)?; - - let identity = user_rep - .get_identity() - .context(format!("Failed to get identity for user in {:?}", path))?; - - if let Some(existing) = existing_users_map.get(&identity) { - if let Some(id) = &existing.id { - user_rep.id = Some(id.clone()); - client - .update_user(id, &user_rep) - .await - .context(format!("Failed to update user {}", user_rep.get_name()))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated user {}", user_rep.get_name())).cyan() - ); - } - } else { - user_rep.id = None; - client - .create_user(&user_rep) - .await - .context(format!("Failed to create user {}", user_rep.get_name()))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created user {}", user_rep.get_name())).green() - ); - } - } - } - } - Ok(()) -} - -async fn apply_authentication_flows( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 8. Apply Authentication Flows - let flows_dir = workspace_dir.join("authentication-flows"); - if async_fs::try_exists(&flows_dir).await? { - let existing_flows = client.get_authentication_flows().await?; - let existing_flows_map: HashMap = existing_flows - .into_iter() - .filter_map(|f| f.get_identity().map(|id| (id, f))) - .collect(); - - let mut entries = async_fs::read_dir(&flows_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut flow_rep: AuthenticationFlowRepresentation = serde_json::from_value(val)?; - - let identity = flow_rep - .get_identity() - .context(format!("Failed to get identity for flow in {:?}", path))?; - - if let Some(existing) = existing_flows_map.get(&identity) { - if let Some(id) = &existing.id { - flow_rep.id = Some(id.clone()); - client - .update_authentication_flow(id, &flow_rep) - .await - .context(format!( - "Failed to update authentication flow {}", - flow_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!( - "Updated authentication flow {}", - flow_rep.get_name() - )) - .cyan() - ); - } - } else { - flow_rep.id = None; - client - .create_authentication_flow(&flow_rep) - .await - .context(format!( - "Failed to create authentication flow {}", - flow_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!( - "Created authentication flow {}", - flow_rep.get_name() - )) - .green() - ); - } - } - } - } - Ok(()) -} - -async fn apply_required_actions( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - // 9. Apply Required Actions - let actions_dir = workspace_dir.join("required-actions"); - if async_fs::try_exists(&actions_dir).await? { - let existing_actions = client.get_required_actions().await?; - let existing_actions_map: HashMap = - existing_actions - .into_iter() - .filter_map(|a| a.get_identity().map(|id| (id, a))) - .collect(); - - let mut entries = async_fs::read_dir(&actions_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let action_rep: RequiredActionProviderRepresentation = serde_json::from_value(val)?; - - let identity = action_rep.get_identity().context(format!( - "Failed to get identity for required action in {:?}", - path - ))?; - - if existing_actions_map.contains_key(&identity) { - client - .update_required_action(&identity, &action_rep) - .await - .context(format!( - "Failed to update required action {}", - action_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated required action {}", action_rep.get_name())).cyan() - ); - } else { - // Register - client - .register_required_action(&action_rep) - .await - .context(format!( - "Failed to register required action {}", - action_rep.get_name() - ))?; - client - .update_required_action(&identity, &action_rep) - .await - .context(format!( - "Failed to configure registered required action {}", - action_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!( - "Registered required action {}", - action_rep.get_name() - )) - .green() - ); - } - } - } - } - Ok(()) -} - -async fn apply_components_or_keys( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - dir_name: &str, - env_vars: Arc>, - planned_files: Arc>>, -) -> Result<()> { - let components_dir = workspace_dir.join(dir_name); - if async_fs::try_exists(&components_dir).await? { - let existing_components = client.get_components().await?; - let mut by_identity: HashMap = HashMap::new(); - type ComponentKey = ( - Option, - Option, - Option, - Option, - ); - let mut by_details: HashMap = HashMap::new(); - - for c in existing_components { - if let Some(id) = c.get_identity() { - by_identity.insert(id, c.clone()); - } - let key = ( - c.name.clone(), - c.sub_type.clone(), - c.provider_id.clone(), - c.parent_id.clone(), - ); - by_details.insert(key, c); - } - - let mut entries = async_fs::read_dir(&components_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if let Some(plan) = &*planned_files - && !plan.contains(&path) - { - continue; - } - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let mut component_rep: ComponentRepresentation = serde_json::from_value(val)?; - - let existing = if let Some(identity) = component_rep.get_identity() { - by_identity.get(&identity).or_else(|| { - let key = ( - component_rep.name.clone(), - component_rep.sub_type.clone(), - component_rep.provider_id.clone(), - component_rep.parent_id.clone(), - ); - by_details.get(&key) - }) - } else { - let key = ( - component_rep.name.clone(), - component_rep.sub_type.clone(), - component_rep.provider_id.clone(), - component_rep.parent_id.clone(), - ); - by_details.get(&key) - }; - - if let Some(existing) = existing { - if let Some(id) = &existing.id { - component_rep.id = Some(id.clone()); - client - .update_component(id, &component_rep) - .await - .context(format!( - "Failed to update component {}", - component_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated component {}", component_rep.get_name())).cyan() - ); - } - } else { - component_rep.id = None; - client - .create_component(&component_rep) - .await - .context(format!( - "Failed to create component {}", - component_rep.get_name() - ))?; - println!( - " {} {}", - SUCCESS_CREATE, - style(format!("Created component {}", component_rep.get_name())).green() - ); - } - } - } - } - Ok(()) -} diff --git a/src/apply/actions.rs b/src/apply/actions.rs new file mode 100644 index 0000000..f35dfa8 --- /dev/null +++ b/src/apply/actions.rs @@ -0,0 +1,284 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, RequiredActionProviderRepresentation}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_required_actions( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 9. Apply Required Actions + let actions_dir = workspace_dir.join("required-actions"); + if async_fs::try_exists(&actions_dir).await? { + let existing_actions = client.get_required_actions().await?; + let existing_actions_map: HashMap = + existing_actions + .into_iter() + .filter_map(|a| a.get_identity().map(|id| (id, a))) + .collect(); + let existing_actions_map = Arc::new(existing_actions_map); + + let mut entries = async_fs::read_dir(&actions_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_actions_map = Arc::clone(&existing_actions_map); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let action_rep: RequiredActionProviderRepresentation = + serde_json::from_value(val)?; + + let identity = action_rep.get_identity().context(format!( + "Failed to get identity for required action in {:?}", + path + ))?; + + if existing_actions_map.contains_key(&identity) { + client + .update_required_action(&identity, &action_rep) + .await + .context(format!( + "Failed to update required action {}", + action_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated required action {}", action_rep.get_name())) + .cyan() + ); + } else { + // Register + client + .register_required_action(&action_rep) + .await + .context(format!( + "Failed to register required action {}", + action_rep.get_name() + ))?; + client + .update_required_action(&identity, &action_rep) + .await + .context(format!( + "Failed to configure registered required action {}", + action_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!( + "Registered required action {}", + action_rep.get_name() + )) + .green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/authentication/required-actions", + get(|| async { + Json(vec![RequiredActionProviderRepresentation { + alias: Some("existing-action".to_string()), + name: Some("Existing Action".to_string()), + provider_id: Some("existing-provider".to_string()), + enabled: Some(true), + default_action: Some(false), + priority: Some(0), + config: None, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/authentication/required-actions/existing-action", + put({ + let count = Arc::clone(&count_clone); + move || { + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { StatusCode::INTERNAL_SERVER_ERROR } + } + }), + ) + .route( + "/admin/realms/test/authentication/register-required-action", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/authentication/required-actions/new-action", + put({ + let count = Arc::clone(&count_clone); + move || { + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { StatusCode::INTERNAL_SERVER_ERROR } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_required_actions_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let actions_dir = temp.path().join("required-actions"); + fs::create_dir(&actions_dir).unwrap(); + + // 1. Test missing identity (alias missing) + let action_no_alias = actions_dir.join("no_alias.yaml"); + fs::write(action_no_alias, "name: No Alias\nproviderId: some-provider").unwrap(); + + let res = apply_required_actions( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to get identity") + ); + + fs::remove_file(actions_dir.join("no_alias.yaml")).unwrap(); + + // 2. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let action_existing = actions_dir.join("existing.yaml"); + fs::write( + action_existing, + "alias: existing-action\nname: Existing Action\nproviderId: existing-provider", + ) + .unwrap(); + + let res = apply_required_actions( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update required action") + ); + + fs::remove_file(actions_dir.join("existing.yaml")).unwrap(); + + // 3. Test register failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let action_new = actions_dir.join("new.yaml"); + fs::write( + action_new, + "alias: new-action\nname: New Action\nproviderId: new-provider", + ) + .unwrap(); + + let res = apply_required_actions( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to register required action") + ); + + // 4. Test update after register failure + // The mock server is set up to succeed on second register call but fail on update + // We just called it once, so next call to register-required-action will succeed (c will be 1) + let res = apply_required_actions( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to configure registered required action") + ); + } +} diff --git a/src/apply/clients.rs b/src/apply/clients.rs new file mode 100644 index 0000000..d891749 --- /dev/null +++ b/src/apply/clients.rs @@ -0,0 +1,242 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_clients( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 3. Apply Clients + let clients_dir = workspace_dir.join("clients"); + if async_fs::try_exists(&clients_dir).await? { + let existing_clients = client.get_clients().await?; + let existing_clients_map: HashMap = existing_clients + .into_iter() + .filter_map(|c| c.get_identity().map(|id| (id, c))) + .collect(); + let existing_clients_map = std::sync::Arc::new(existing_clients_map); + + let mut entries = async_fs::read_dir(&clients_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_clients_map = existing_clients_map.clone(); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut client_rep: ClientRepresentation = serde_json::from_value(val)?; + + let identity = client_rep + .get_identity() + .context(format!("Failed to get identity for client in {:?}", path))?; + + if let Some(existing) = existing_clients_map.get(&identity) { + if let Some(id) = &existing.id { + client_rep.id = Some(id.clone()); // Use remote ID + client + .update_client(id, &client_rep) + .await + .context(format!( + "Failed to update client {}", + client_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated client {}", client_rep.get_name())).cyan() + ); + } + } else { + client_rep.id = None; // Don't send ID on create + client.create_client(&client_rep).await.context(format!( + "Failed to create client {}", + client_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created client {}", client_rep.get_name())).green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + http::StatusCode, + routing::{get, put}, + Json, Router, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/clients", + get(|| async { + Json(vec![ClientRepresentation { + id: Some("existing-id".to_string()), + client_id: Some("existing-client".to_string()), + name: Some("Existing Client".to_string()), + description: None, + enabled: Some(true), + protocol: None, + redirect_uris: None, + web_origins: None, + public_client: None, + bearer_only: None, + service_accounts_enabled: None, + extra: Default::default(), + }]) + }) + .post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ) + .route( + "/admin/realms/test/clients/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_clients_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let clients_dir = temp.path().join("clients"); + fs::create_dir(&clients_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let client_existing = clients_dir.join("existing.yaml"); + fs::write( + client_existing, + "clientId: existing-client\nname: Existing Client", + ) + .unwrap(); + + let res = apply_clients( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update client") + ); + + fs::remove_file(clients_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let client_new = clients_dir.join("new.yaml"); + fs::write(client_new, "clientId: new-client\nname: New Client").unwrap(); + + let res = apply_clients( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create client") + ); + + // 3. Test invalid YAML + let client_invalid = clients_dir.join("invalid.yaml"); + fs::write(client_invalid, "invalid: yaml: :").unwrap(); + let res = apply_clients( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to parse YAML file") + ); + } +} diff --git a/src/apply/components.rs b/src/apply/components.rs new file mode 100644 index 0000000..aa5a0e6 --- /dev/null +++ b/src/apply/components.rs @@ -0,0 +1,262 @@ +use crate::client::KeycloakClient; +use crate::models::{ComponentRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_components_or_keys( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + dir_name: &str, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + let components_dir = workspace_dir.join(dir_name); + if async_fs::try_exists(&components_dir).await? { + let existing_components = client.get_components().await?; + let mut by_identity: HashMap = HashMap::new(); + type ComponentKey = ( + Option, + Option, + Option, + Option, + ); + let mut by_details: HashMap = HashMap::new(); + + for c in existing_components { + if let Some(id) = c.get_identity() { + by_identity.insert(id, c.clone()); + } + let key = ( + c.name.clone(), + c.sub_type.clone(), + c.provider_id.clone(), + c.parent_id.clone(), + ); + by_details.insert(key, c); + } + let by_identity = Arc::new(by_identity); + let by_details = Arc::new(by_details); + + let mut entries = async_fs::read_dir(&components_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let by_identity = Arc::clone(&by_identity); + let by_details = Arc::clone(&by_details); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut component_rep: ComponentRepresentation = serde_json::from_value(val)?; + + let existing = if let Some(identity) = component_rep.get_identity() { + by_identity.get(&identity).or_else(|| { + let key = ( + component_rep.name.clone(), + component_rep.sub_type.clone(), + component_rep.provider_id.clone(), + component_rep.parent_id.clone(), + ); + by_details.get(&key) + }) + } else { + let key = ( + component_rep.name.clone(), + component_rep.sub_type.clone(), + component_rep.provider_id.clone(), + component_rep.parent_id.clone(), + ); + by_details.get(&key) + }; + + if let Some(existing) = existing { + if let Some(id) = &existing.id { + component_rep.id = Some(id.clone()); + client + .update_component(id, &component_rep) + .await + .context(format!( + "Failed to update component {}", + component_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated component {}", component_rep.get_name())) + .cyan() + ); + } + } else { + component_rep.id = None; + client + .create_component(&component_rep) + .await + .context(format!( + "Failed to create component {}", + component_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created component {}", component_rep.get_name())) + .green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/components", + get(|| async { + Json(vec![ComponentRepresentation { + id: Some("existing-id".to_string()), + name: Some("Existing Component".to_string()), + provider_id: Some("existing-provider".to_string()), + provider_type: Some("existing-type".to_string()), + parent_id: Some("test".to_string()), + sub_type: None, + config: None, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/components/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/components", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_components_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let components_dir = temp.path().join("components"); + fs::create_dir(&components_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let comp_existing = components_dir.join("existing.yaml"); + fs::write(comp_existing, "name: Existing Component\nid: existing-id").unwrap(); + + let res = apply_components_or_keys( + &client, + temp.path(), + "components", + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update component") + ); + + fs::remove_file(components_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let comp_new = components_dir.join("new.yaml"); + fs::write(comp_new, "name: New Component\nproviderId: new-provider").unwrap(); + + let res = apply_components_or_keys( + &client, + temp.path(), + "components", + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create component") + ); + } +} diff --git a/src/apply/flows.rs b/src/apply/flows.rs new file mode 100644 index 0000000..045fe3d --- /dev/null +++ b/src/apply/flows.rs @@ -0,0 +1,232 @@ +use crate::client::KeycloakClient; +use crate::models::{AuthenticationFlowRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_authentication_flows( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 8. Apply Authentication Flows + let flows_dir = workspace_dir.join("authentication-flows"); + if async_fs::try_exists(&flows_dir).await? { + let existing_flows = client.get_authentication_flows().await?; + let existing_flows_map: HashMap = existing_flows + .into_iter() + .filter_map(|f| f.get_identity().map(|id| (id, f))) + .collect(); + let existing_flows_map = Arc::new(existing_flows_map); + + let mut entries = async_fs::read_dir(&flows_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_flows_map = Arc::clone(&existing_flows_map); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut flow_rep: AuthenticationFlowRepresentation = + serde_json::from_value(val)?; + + let identity = flow_rep + .get_identity() + .context(format!("Failed to get identity for flow in {:?}", path))?; + + if let Some(existing) = existing_flows_map.get(&identity) { + if let Some(id) = &existing.id { + flow_rep.id = Some(id.clone()); + client + .update_authentication_flow(id, &flow_rep) + .await + .context(format!( + "Failed to update authentication flow {}", + flow_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!( + "Updated authentication flow {}", + flow_rep.get_name() + )) + .cyan() + ); + } + } else { + flow_rep.id = None; + client + .create_authentication_flow(&flow_rep) + .await + .context(format!( + "Failed to create authentication flow {}", + flow_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!( + "Created authentication flow {}", + flow_rep.get_name() + )) + .green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/authentication/flows", + get(|| async { + Json(vec![AuthenticationFlowRepresentation { + id: Some("existing-id".to_string()), + alias: Some("existing-flow".to_string()), + description: Some("Existing Flow".to_string()), + provider_id: None, + top_level: Some(true), + built_in: Some(false), + authentication_executions: None, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/authentication/flows/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/authentication/flows", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_authentication_flows_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let flows_dir = temp.path().join("authentication-flows"); + fs::create_dir(&flows_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let flow_existing = flows_dir.join("existing.yaml"); + fs::write(flow_existing, "alias: existing-flow\nid: existing-id").unwrap(); + + let res = apply_authentication_flows( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update authentication flow") + ); + + fs::remove_file(flows_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let flow_new = flows_dir.join("new.yaml"); + fs::write(flow_new, "alias: new-flow\nproviderId: basic-flow").unwrap(); + + let res = apply_authentication_flows( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create authentication flow") + ); + } +} diff --git a/src/apply/groups.rs b/src/apply/groups.rs new file mode 100644 index 0000000..bbee945 --- /dev/null +++ b/src/apply/groups.rs @@ -0,0 +1,214 @@ +use crate::client::KeycloakClient; +use crate::models::{GroupRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_groups( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 6. Apply Groups + let groups_dir = workspace_dir.join("groups"); + if async_fs::try_exists(&groups_dir).await? { + let existing_groups = client.get_groups().await?; + let existing_groups_map: HashMap = existing_groups + .into_iter() + .filter_map(|g| g.get_identity().map(|id| (id, g))) + .collect(); + let existing_groups_map = Arc::new(existing_groups_map); + + let mut entries = async_fs::read_dir(&groups_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_groups_map = Arc::clone(&existing_groups_map); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut group_rep: GroupRepresentation = serde_json::from_value(val)?; + + let identity = group_rep + .get_identity() + .context(format!("Failed to get identity for group in {:?}", path))?; + + if let Some(existing) = existing_groups_map.get(&identity) { + if let Some(id) = &existing.id { + group_rep.id = Some(id.clone()); + client.update_group(id, &group_rep).await.context(format!( + "Failed to update group {}", + group_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated group {}", group_rep.get_name())).cyan() + ); + } + } else { + group_rep.id = None; + client + .create_group(&group_rep) + .await + .context(format!("Failed to create group {}", group_rep.get_name()))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created group {}", group_rep.get_name())).green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/groups", + get(|| async { + Json(vec![GroupRepresentation { + id: Some("existing-id".to_string()), + name: Some("Existing Group".to_string()), + path: Some("/existing-group".to_string()), + sub_groups: None, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/groups/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/groups", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_groups_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let groups_dir = temp.path().join("groups"); + fs::create_dir(&groups_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let group_existing = groups_dir.join("existing.yaml"); + fs::write(group_existing, "name: Existing Group\nid: existing-id").unwrap(); + + let res = apply_groups( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update group") + ); + + fs::remove_file(groups_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let group_new = groups_dir.join("new.yaml"); + fs::write(group_new, "name: New Group").unwrap(); + + let res = apply_groups( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create group") + ); + } +} diff --git a/src/apply/idps.rs b/src/apply/idps.rs new file mode 100644 index 0000000..89e8201 --- /dev/null +++ b/src/apply/idps.rs @@ -0,0 +1,97 @@ +use crate::client::KeycloakClient; +use crate::models::{IdentityProviderRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_identity_providers( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 4. Apply Identity Providers + let idps_dir = workspace_dir.join("identity-providers"); + if async_fs::try_exists(&idps_dir).await? { + let existing_idps = client.get_identity_providers().await?; + let existing_idps_map: HashMap = existing_idps + .into_iter() + .filter_map(|i| i.get_identity().map(|id| (id, i))) + .collect(); + let existing_idps_map = std::sync::Arc::new(existing_idps_map); + + let mut entries = async_fs::read_dir(&idps_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_idps_map = existing_idps_map.clone(); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut idp_rep: IdentityProviderRepresentation = serde_json::from_value(val)?; + + let identity = idp_rep + .get_identity() + .context(format!("Failed to get identity for IDP in {:?}", path))?; + + if let Some(existing) = existing_idps_map.get(&identity) { + if let Some(internal_id) = &existing.internal_id { + idp_rep.internal_id = Some(internal_id.clone()); + client + .update_identity_provider(&identity, &idp_rep) + .await + .context(format!( + "Failed to update identity provider {}", + idp_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated identity provider {}", idp_rep.get_name())) + .cyan() + ); + } + } else { + idp_rep.internal_id = None; + client + .create_identity_provider(&idp_rep) + .await + .context(format!( + "Failed to create identity provider {}", + idp_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created identity provider {}", idp_rep.get_name())) + .green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} diff --git a/src/apply/mod.rs b/src/apply/mod.rs new file mode 100644 index 0000000..8b9bb19 --- /dev/null +++ b/src/apply/mod.rs @@ -0,0 +1,219 @@ +pub mod actions; +pub mod clients; +pub mod components; +pub mod flows; +pub mod groups; +pub mod idps; +pub mod realm; +pub mod roles; +pub mod scopes; +pub mod users; + +use crate::client::KeycloakClient; +use anyhow::Result; +use console::{Emoji, style}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; + +pub static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); +pub static SUCCESS_CREATE: Emoji<'_, '_> = Emoji("✨ ", "+ "); +pub static SUCCESS_UPDATE: Emoji<'_, '_> = Emoji("🔄 ", "~ "); +pub static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); + +pub async fn run( + client: &KeycloakClient, + workspace_dir: PathBuf, + realms_to_apply: &[String], + yes: bool, +) -> Result<()> { + if !workspace_dir.exists() { + anyhow::bail!("Input directory {:?} does not exist", workspace_dir); + } + + // Load .secrets from input directory if it exists + let env_path = workspace_dir.join(".secrets"); + if env_path.exists() { + dotenvy::from_path(&env_path).ok(); + } + + let env_vars = Arc::new(std::env::vars().collect::>()); + + // Check for .kcdplan + let plan_path = workspace_dir.join(".kcdplan"); + let planned_files = if plan_path.exists() { + let content = async_fs::read_to_string(&plan_path).await?; + let items: Vec = serde_json::from_str(&content)?; + if items.is_empty() { + if !yes { + let proceed = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt( + "No planned changes found. Send everything to Keycloak anyway?", + ) + .default(false) + .interact()?; + if !proceed { + println!("Aborted."); + return Ok(()); + } + } + Arc::new(None) + } else { + let hashset: HashSet = items.into_iter().collect(); + Arc::new(Some(hashset)) + } + } else { + if !yes { + let proceed = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("No planned changes found. Send everything to Keycloak anyway?") + .default(false) + .interact()?; + if !proceed { + println!("Aborted."); + return Ok(()); + } + } + Arc::new(None) + }; + + let realms = if realms_to_apply.is_empty() { + let mut dirs = Vec::new(); + let mut entries = async_fs::read_dir(&workspace_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await?.is_dir() { + dirs.push(entry.file_name().to_string_lossy().to_string()); + } + } + dirs + } else { + realms_to_apply.to_vec() + }; + + if realms.is_empty() { + println!( + "{} {}", + WARN, + style(format!("No realms found to apply in {:?}", workspace_dir)).yellow() + ); + return Ok(()); + } + + for realm_name in realms { + println!( + "\n{} {}", + ACTION, + style(format!("Applying realm: {}", realm_name)) + .cyan() + .bold() + ); + let mut realm_client = client.clone(); + realm_client.set_target_realm(realm_name.clone()); + let realm_dir = workspace_dir.join(&realm_name); + apply_single_realm( + &realm_client, + realm_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + } + + // Success - remove plan + if plan_path.exists() { + let _ = async_fs::remove_file(plan_path).await; + } + + Ok(()) +} + +async fn apply_single_realm( + client: &KeycloakClient, + workspace_dir: PathBuf, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + realm::apply_realm( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + roles::apply_roles( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + idps::apply_identity_providers( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + clients::apply_clients( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + scopes::apply_client_scopes( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + groups::apply_groups( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + users::apply_users( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + flows::apply_authentication_flows( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + actions::apply_required_actions( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + components::apply_components_or_keys( + client, + &workspace_dir, + "components", + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + components::apply_components_or_keys( + client, + &workspace_dir, + "keys", + Arc::clone(&env_vars), + Arc::clone(&planned_files), + ) + .await?; + + Ok(()) +} diff --git a/src/apply/realm.rs b/src/apply/realm.rs new file mode 100644 index 0000000..a75fb1c --- /dev/null +++ b/src/apply/realm.rs @@ -0,0 +1,43 @@ +use crate::client::KeycloakClient; +use crate::models::RealmRepresentation; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::SUCCESS_UPDATE; + +pub async fn apply_realm( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 1. Apply Realm + let realm_path = workspace_dir.join("realm.yaml"); + if let Some(plan) = &*planned_files + && !plan.contains(&realm_path) + { + return Ok(()); + } + if async_fs::try_exists(&realm_path).await? { + let content = async_fs::read_to_string(&realm_path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", realm_path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let realm_rep: RealmRepresentation = serde_json::from_value(val)?; + client + .update_realm(&realm_rep) + .await + .context("Failed to update realm")?; + println!( + " {} {}", + SUCCESS_UPDATE, + style("Updated realm configuration").cyan() + ); + } + Ok(()) +} diff --git a/src/apply/roles.rs b/src/apply/roles.rs new file mode 100644 index 0000000..9e267f7 --- /dev/null +++ b/src/apply/roles.rs @@ -0,0 +1,221 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, RoleRepresentation}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_roles( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 2. Apply Roles + let roles_dir = workspace_dir.join("roles"); + if async_fs::try_exists(&roles_dir).await? { + let existing_roles = client.get_roles().await?; + let existing_roles_map: HashMap = existing_roles + .into_iter() + .filter_map(|r| { + let identity = r.get_identity(); + let id = r.id.clone(); + match (identity, id) { + (Some(identity), Some(id)) => Some((identity, id)), + _ => None, + } + }) + .collect(); + let existing_roles_map = std::sync::Arc::new(existing_roles_map); + + let mut entries = async_fs::read_dir(&roles_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_roles_map = existing_roles_map.clone(); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut role_rep: RoleRepresentation = serde_json::from_value(val)?; + + let identity = role_rep + .get_identity() + .context(format!("Failed to get identity for role in {:?}", path))?; + + if let Some(id) = existing_roles_map.get(&identity) { + role_rep.id = Some(id.clone()); // Use remote ID + client + .update_role(id, &role_rep) + .await + .context(format!("Failed to update role {}", role_rep.get_name()))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated role {}", role_rep.get_name())).cyan() + ); + } else { + role_rep.id = None; // Don't send ID on create + client + .create_role(&role_rep) + .await + .context(format!("Failed to create role {}", role_rep.get_name()))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created role {}", role_rep.get_name())).green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/roles", + get(|| async { + Json(vec![RoleRepresentation { + id: Some("existing-id".to_string()), + name: "existing-role".to_string(), + description: None, + container_id: None, + composite: false, + client_role: false, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/roles-by-id/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/roles", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_roles_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let roles_dir = temp.path().join("roles"); + fs::create_dir(&roles_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let role_existing = roles_dir.join("existing.yaml"); + fs::write(role_existing, "name: existing-role\nid: existing-id").unwrap(); + + let res = apply_roles( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update role") + ); + + fs::remove_file(roles_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let role_new = roles_dir.join("new.yaml"); + fs::write(role_new, "name: new-role").unwrap(); + + let res = apply_roles( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create role") + ); + } +} diff --git a/src/apply/scopes.rs b/src/apply/scopes.rs new file mode 100644 index 0000000..72634d0 --- /dev/null +++ b/src/apply/scopes.rs @@ -0,0 +1,223 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientScopeRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_client_scopes( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 5. Apply Client Scopes + let scopes_dir = workspace_dir.join("client-scopes"); + if async_fs::try_exists(&scopes_dir).await? { + let existing_scopes = client.get_client_scopes().await?; + let existing_scopes_map: HashMap = existing_scopes + .into_iter() + .filter_map(|s| s.get_identity().map(|id| (id, s))) + .collect(); + let existing_scopes_map = Arc::new(existing_scopes_map); + + let mut entries = async_fs::read_dir(&scopes_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_scopes_map = Arc::clone(&existing_scopes_map); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut scope_rep: ClientScopeRepresentation = serde_json::from_value(val)?; + + let identity = scope_rep.get_identity().context(format!( + "Failed to get identity for client scope in {:?}", + path + ))?; + + if let Some(existing) = existing_scopes_map.get(&identity) { + if let Some(id) = &existing.id { + scope_rep.id = Some(id.clone()); + client + .update_client_scope(id, &scope_rep) + .await + .context(format!( + "Failed to update client scope {}", + scope_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated client scope {}", scope_rep.get_name())) + .cyan() + ); + } + } else { + scope_rep.id = None; + client + .create_client_scope(&scope_rep) + .await + .context(format!( + "Failed to create client scope {}", + scope_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created client scope {}", scope_rep.get_name())).green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/client-scopes", + get(|| async { + Json(vec![ClientScopeRepresentation { + id: Some("existing-id".to_string()), + name: Some("existing-scope".to_string()), + description: None, + protocol: None, + attributes: None, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/client-scopes/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/client-scopes", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_client_scopes_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let scopes_dir = temp.path().join("client-scopes"); + fs::create_dir(&scopes_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let scope_existing = scopes_dir.join("existing.yaml"); + fs::write(scope_existing, "name: existing-scope\nid: existing-id").unwrap(); + + let res = apply_client_scopes( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update client scope") + ); + + fs::remove_file(scopes_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let scope_new = scopes_dir.join("new.yaml"); + fs::write(scope_new, "name: new-scope").unwrap(); + + let res = apply_client_scopes( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create client scope") + ); + } +} diff --git a/src/apply/users.rs b/src/apply/users.rs new file mode 100644 index 0000000..8a4c775 --- /dev/null +++ b/src/apply/users.rs @@ -0,0 +1,218 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, UserRepresentation}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::style; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; +use tokio::task::JoinSet; + +use super::{SUCCESS_CREATE, SUCCESS_UPDATE}; + +pub async fn apply_users( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, +) -> Result<()> { + // 7. Apply Users + let users_dir = workspace_dir.join("users"); + if async_fs::try_exists(&users_dir).await? { + let existing_users = client.get_users().await?; + let existing_users_map: HashMap = existing_users + .into_iter() + .filter_map(|u| u.get_identity().map(|id| (id, u))) + .collect(); + let existing_users_map = Arc::new(existing_users_map); + + let mut entries = async_fs::read_dir(&users_dir).await?; + let mut set = JoinSet::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if let Some(plan) = &*planned_files + && !plan.contains(&path) + { + continue; + } + if path.extension().is_some_and(|ext| ext == "yaml") { + let client = client.clone(); + let existing_users_map = Arc::clone(&existing_users_map); + let env_vars = Arc::clone(&env_vars); + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let mut user_rep: UserRepresentation = serde_json::from_value(val)?; + + let identity = user_rep + .get_identity() + .context(format!("Failed to get identity for user in {:?}", path))?; + + if let Some(existing) = existing_users_map.get(&identity) { + if let Some(id) = &existing.id { + user_rep.id = Some(id.clone()); + client.update_user(id, &user_rep).await.context(format!( + "Failed to update user {}", + user_rep.get_name() + ))?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated user {}", user_rep.get_name())).cyan() + ); + } + } else { + user_rep.id = None; + client + .create_user(&user_rep) + .await + .context(format!("Failed to create user {}", user_rep.get_name()))?; + println!( + " {} {}", + SUCCESS_CREATE, + style(format!("Created user {}", user_rep.get_name())).green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + } + while let Some(res) = set.join_next().await { + res??; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::KeycloakClient; + use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post, put}, + }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::net::TcpListener; + + async fn start_mock_server() -> (String, Arc) { + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = Arc::clone(&call_count); + + let app = Router::new() + .route( + "/admin/realms/test/users", + get(|| async { + Json(vec![UserRepresentation { + id: Some("existing-id".to_string()), + username: Some("existing-user".to_string()), + enabled: Some(true), + first_name: None, + last_name: None, + email: None, + email_verified: None, + credentials: None, + extra: Default::default(), + }]) + }), + ) + .route( + "/admin/realms/test/users/existing-id", + put({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } + } + }), + ) + .route( + "/admin/realms/test/users", + post({ + let count = Arc::clone(&count_clone); + move || { + let c = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if c == 0 { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::CREATED + } + } + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), call_count) + } + + #[tokio::test] + async fn test_apply_users_error_paths() { + let (server_url, call_count) = start_mock_server().await; + let mut client = KeycloakClient::new(server_url); + client.set_target_realm("test".to_string()); + client.set_token("mock_token".to_string()); + + let temp = tempdir().unwrap(); + let users_dir = temp.path().join("users"); + fs::create_dir(&users_dir).unwrap(); + + // 1. Test update failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let user_existing = users_dir.join("existing.yaml"); + fs::write(user_existing, "username: existing-user\nid: existing-id").unwrap(); + + let res = apply_users( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to update user") + ); + + fs::remove_file(users_dir.join("existing.yaml")).unwrap(); + + // 2. Test create failure + call_count.store(0, std::sync::atomic::Ordering::SeqCst); + let user_new = users_dir.join("new.yaml"); + fs::write(user_new, "username: new-user").unwrap(); + + let res = apply_users( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create user") + ); + } +} diff --git a/src/clean.rs b/src/clean.rs index 491efe6..92f1d0b 100644 --- a/src/clean.rs +++ b/src/clean.rs @@ -62,6 +62,8 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) } } + let mut join_set = tokio::task::JoinSet::new(); + for target in targets { if target == workspace_dir && realms_to_clean.is_empty() { println!( @@ -72,15 +74,19 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) let mut entries = fs::read_dir(&workspace_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); - if path.is_dir() { - fs::remove_dir_all(&path) - .await - .context(format!("Failed to remove dir {:?}", path))?; - } else { - fs::remove_file(&path) - .await - .context(format!("Failed to remove file {:?}", path))?; - } + let file_type = entry.file_type().await?; + join_set.spawn(async move { + if file_type.is_dir() { + fs::remove_dir_all(&path) + .await + .context(format!("Failed to remove dir {:?}", path))?; + } else { + fs::remove_file(&path) + .await + .context(format!("Failed to remove file {:?}", path))?; + } + Ok::<(), anyhow::Error>(()) + }); } } else { println!( @@ -88,18 +94,26 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) ACTION, style(format!("Cleaning realm directory {:?}", target)).cyan() ); - if target.is_dir() { - fs::remove_dir_all(&target) - .await - .context(format!("Failed to remove dir {:?}", target))?; - } else { - fs::remove_file(&target) - .await - .context(format!("Failed to remove file {:?}", target))?; - } + join_set.spawn(async move { + let metadata = fs::metadata(&target).await?; + if metadata.is_dir() { + fs::remove_dir_all(&target) + .await + .context(format!("Failed to remove dir {:?}", target))?; + } else { + fs::remove_file(&target) + .await + .context(format!("Failed to remove file {:?}", target))?; + } + Ok::<(), anyhow::Error>(()) + }); } } + while let Some(res) = join_set.join_next().await { + res??; + } + println!( "{} {}", SUCCESS, diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index dbb3b7b..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,1161 +0,0 @@ -use crate::models::{ - ClientRepresentation, ClientScopeRepresentation, ComponentRepresentation, - CredentialRepresentation, GroupRepresentation, IdentityProviderRepresentation, - RoleRepresentation, UserRepresentation, -}; -use anyhow::{Context, Result}; -use console::{Emoji, style}; -use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; -use tokio::fs; - -static SUCCESS: Emoji<'_, '_> = Emoji("✨ ", "* "); -static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); -static INFO: Emoji<'_, '_> = Emoji("💡 ", "i "); - -pub async fn run(workspace_dir: PathBuf) -> Result<()> { - println!( - "{} {}", - INFO, - style("Welcome to kcd interactive CLI!").cyan().bold() - ); - let theme = ColorfulTheme::default(); - let selections = &[ - "Create User", - "Change User Password", - "Create Client", - "Create Role", - "Create Group", - "Create Identity Provider", - "Create Client Scope", - "Rotate Keys", - "Exit", - ]; - - loop { - let selection = Select::with_theme(&theme) - .with_prompt("What would you like to do?") - .default(0) - .items(&selections[..]) - .interact()?; - - match selection { - 0 => { - if let Err(e) = create_user_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error creating user: {}", e)).red() - ); - } - } - 1 => { - if let Err(e) = change_user_password_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error changing password: {}", e)).red() - ); - } - } - 2 => { - if let Err(e) = create_client_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error creating client: {}", e)).red() - ); - } - } - 3 => { - if let Err(e) = create_role_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error creating role: {}", e)).red() - ); - } - } - 4 => { - if let Err(e) = create_group_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error creating group: {}", e)).red() - ); - } - } - 5 => { - if let Err(e) = create_idp_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error creating IDP: {}", e)).red() - ); - } - } - 6 => { - if let Err(e) = create_client_scope_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error creating client scope: {}", e)).red() - ); - } - } - 7 => { - if let Err(e) = rotate_keys_interactive(&workspace_dir).await { - println!( - "{} {}", - ERROR, - style(format!("Error rotating keys: {}", e)).red() - ); - } - } - 8 => { - println!("{} {}", INFO, style("Exiting...").cyan()); - break; - } - _ => unreachable!(), - } - } - - Ok(()) -} - -async fn create_role_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let name: String = Input::with_theme(&theme) - .with_prompt("Role Name") - .interact_text()?; - - let description: String = Input::with_theme(&theme) - .with_prompt("Description") - .allow_empty(true) - .interact_text()?; - - let is_client_role = Confirm::with_theme(&theme) - .with_prompt("Is this a client role?") - .default(false) - .interact()?; - - let client_id = if is_client_role { - let id: String = Input::with_theme(&theme) - .with_prompt("Client ID") - .interact_text()?; - Some(id) - } else { - None - }; - - let description_opt = if description.is_empty() { - None - } else { - Some(description) - }; - - create_role_yaml(workspace_dir, &realm, &name, description_opt, client_id).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for role '{}' in realm '{}'.", - name, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_role_yaml( - workspace_dir: &Path, - realm: &str, - name: &str, - description: Option, - client_id: Option, -) -> Result<()> { - let role = RoleRepresentation { - id: None, - name: name.to_string(), - description, - container_id: None, - composite: false, - client_role: client_id.is_some(), - extra: HashMap::new(), - }; - - let realm_dir = workspace_dir.join(realm); - let roles_dir = if let Some(cid) = &client_id { - realm_dir.join("clients").join(cid).join("roles") - } else { - realm_dir.join("roles") - }; - - fs::create_dir_all(&roles_dir) - .await - .context("Failed to create roles directory")?; - - let file_path = roles_dir.join(format!("{}.yaml", name)); - let yaml = serde_yaml::to_string(&role).context("Failed to serialize role to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write role YAML file")?; - - Ok(()) -} - -async fn create_group_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let name: String = Input::with_theme(&theme) - .with_prompt("Group Name") - .interact_text()?; - - create_group_yaml(workspace_dir, &realm, &name).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for group '{}' in realm '{}'.", - name, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_group_yaml(workspace_dir: &Path, realm: &str, name: &str) -> Result<()> { - let group = GroupRepresentation { - id: None, - name: Some(name.to_string()), - path: None, - sub_groups: None, - extra: HashMap::new(), - }; - - let groups_dir = workspace_dir.join(realm).join("groups"); - fs::create_dir_all(&groups_dir) - .await - .context("Failed to create groups directory")?; - - let file_path = groups_dir.join(format!("{}.yaml", name)); - let yaml = serde_yaml::to_string(&group).context("Failed to serialize group to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write group YAML file")?; - - Ok(()) -} - -async fn create_idp_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let alias: String = Input::with_theme(&theme) - .with_prompt("Alias (e.g., google)") - .interact_text()?; - - let provider_id: String = Input::with_theme(&theme) - .with_prompt("Provider ID (e.g., google, github, oidc)") - .default(alias.clone()) - .interact_text()?; - - create_idp_yaml(workspace_dir, &realm, &alias, &provider_id).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for Identity Provider '{}' in realm '{}'.", - alias, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_idp_yaml( - workspace_dir: &Path, - realm: &str, - alias: &str, - provider_id: &str, -) -> Result<()> { - let idp = IdentityProviderRepresentation { - internal_id: None, - alias: Some(alias.to_string()), - provider_id: Some(provider_id.to_string()), - enabled: Some(true), - update_profile_first_login_mode: Some("on".to_string()), - trust_email: Some(false), - store_token: Some(false), - add_read_token_role_on_create: Some(false), - authenticate_by_default: Some(false), - link_only: Some(false), - first_broker_login_flow_alias: Some("first broker login".to_string()), - post_broker_login_flow_alias: None, - display_name: Some(alias.to_string()), - config: Some(HashMap::new()), - extra: HashMap::new(), - }; - - let idp_dir = workspace_dir.join(realm).join("identity-providers"); - fs::create_dir_all(&idp_dir) - .await - .context("Failed to create identity-providers directory")?; - - let file_path = idp_dir.join(format!("{}.yaml", alias)); - let yaml = serde_yaml::to_string(&idp).context("Failed to serialize IDP to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write IDP YAML file")?; - - Ok(()) -} - -async fn create_client_scope_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let name: String = Input::with_theme(&theme) - .with_prompt("Scope Name") - .interact_text()?; - - let protocol: String = Input::with_theme(&theme) - .with_prompt("Protocol") - .default("openid-connect".to_string()) - .interact_text()?; - - create_client_scope_yaml(workspace_dir, &realm, &name, &protocol).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for client scope '{}' in realm '{}'.", - name, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_client_scope_yaml( - workspace_dir: &Path, - realm: &str, - name: &str, - protocol: &str, -) -> Result<()> { - let scope = ClientScopeRepresentation { - id: None, - name: Some(name.to_string()), - description: None, - protocol: Some(protocol.to_string()), - attributes: Some(HashMap::new()), - extra: HashMap::new(), - }; - - let scopes_dir = workspace_dir.join(realm).join("client-scopes"); - fs::create_dir_all(&scopes_dir) - .await - .context("Failed to create client-scopes directory")?; - - let file_path = scopes_dir.join(format!("{}.yaml", name)); - let yaml = serde_yaml::to_string(&scope).context("Failed to serialize client scope to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write client scope YAML file")?; - - Ok(()) -} - -async fn rotate_keys_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let rotated_count = rotate_keys_yaml(workspace_dir, &realm).await?; - - if rotated_count > 0 { - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated {} rotated key component(s) for realm '{}'.", - rotated_count, realm - )) - .green() - ); - } else { - println!( - "{} {}", - INFO, - style(format!( - "No key providers found to rotate for realm '{}'.", - realm - )) - .cyan() - ); - } - - Ok(()) -} - -pub async fn rotate_keys_yaml(workspace_dir: &Path, realm: &str) -> Result { - let keys_dir = workspace_dir.join(realm).join("components"); - - if !keys_dir.exists() { - return Ok(0); - } - - let mut rotated_count = 0; - let mut entries = fs::read_dir(&keys_dir) - .await - .context("Failed to read components directory")?; - - while let Some(entry) = entries - .next_entry() - .await - .context("Failed to read directory entry")? - { - let path = entry.path(); - if path.is_file() - && path - .extension() - .is_some_and(|ext| ext == "yaml" || ext == "yml") - { - let yaml_content = fs::read_to_string(&path) - .await - .context("Failed to read key YAML file")?; - - #[allow(clippy::collapsible_if)] - if let Ok(component) = serde_yaml::from_str::(&yaml_content) { - if component.provider_type.as_deref() == Some("org.keycloak.keys.KeyProvider") { - let mut new_component = component.clone(); - new_component.id = None; - - let old_name = new_component - .name - .clone() - .unwrap_or_else(|| "key".to_string()); - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX EPOCH")? - .as_secs(); - new_component.name = Some(format!("{}-rotated-{}", old_name, timestamp)); - - #[allow(clippy::collapsible_if)] - if let Some(config) = &mut new_component.config { - if let Some(priority_vals) = config.get_mut("priority") { - if let Some(arr) = priority_vals.as_array_mut() { - if let Some(first) = arr.first_mut() { - if let Some(p_str) = first.as_str() { - if let Ok(p_num) = p_str.parse::() { - *first = - serde_json::Value::String((p_num + 10).to_string()); - } - } - } - } - } - } - - let new_filename = format!("{}.yaml", new_component.name.as_deref().unwrap()); - let new_file_path = keys_dir.join(new_filename); - - let yaml = serde_yaml::to_string(&new_component) - .context("Failed to serialize rotated key to YAML")?; - fs::write(&new_file_path, yaml) - .await - .context("Failed to write rotated key YAML file")?; - - rotated_count += 1; - } - } - } - } - - Ok(rotated_count) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[tokio::test] - async fn test_create_user_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_user_yaml( - workspace_dir, - "master", - "testuser", - Some("test@example.com".to_string()), - Some("Test".to_string()), - Some("User".to_string()), - ) - .await - .unwrap(); - - let file_path = workspace_dir - .join("master") - .join("users") - .join("testuser.yaml"); - assert!(file_path.exists()); - - let content = fs::read_to_string(&file_path).await.unwrap(); - let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); - - assert_eq!(user.username.as_deref(), Some("testuser")); - assert_eq!(user.email.as_deref(), Some("test@example.com")); - assert_eq!(user.first_name.as_deref(), Some("Test")); - assert_eq!(user.last_name.as_deref(), Some("User")); - } - - #[tokio::test] - async fn test_create_user_yaml_partial() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_user_yaml(workspace_dir, "master", "user2", None, None, None) - .await - .unwrap(); - - let file_path = workspace_dir - .join("master") - .join("users") - .join("user2.yaml"); - let content = fs::read_to_string(&file_path).await.unwrap(); - let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); - - assert_eq!(user.username.as_deref(), Some("user2")); - assert_eq!(user.email, None); - assert_eq!(user.first_name, None); - assert_eq!(user.last_name, None); - } - - #[tokio::test] - async fn test_change_user_password_yaml_existing_password() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_user_yaml(workspace_dir, "master", "testuser", None, None, None) - .await - .unwrap(); - - // Add first password - change_user_password_yaml(workspace_dir, "master", "testuser", "pass1") - .await - .unwrap(); - - // Change password (should update existing) - change_user_password_yaml(workspace_dir, "master", "testuser", "pass2") - .await - .unwrap(); - - let file_path = workspace_dir - .join("master") - .join("users") - .join("testuser.yaml"); - let content = fs::read_to_string(&file_path).await.unwrap(); - let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); - - let credentials = user.credentials.unwrap(); - assert_eq!(credentials.len(), 1); - assert_eq!(credentials[0].value.as_deref(), Some("pass2")); - } - - #[tokio::test] - async fn test_change_user_password_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_user_yaml(workspace_dir, "master", "testuser", None, None, None) - .await - .unwrap(); - - change_user_password_yaml(workspace_dir, "master", "testuser", "newpass123") - .await - .unwrap(); - - let file_path = workspace_dir - .join("master") - .join("users") - .join("testuser.yaml"); - let content = fs::read_to_string(&file_path).await.unwrap(); - let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); - - let credentials = user.credentials.expect("Credentials should not be None"); - assert_eq!(credentials.len(), 1); - assert_eq!(credentials[0].type_.as_deref(), Some("password")); - assert_eq!(credentials[0].value.as_deref(), Some("newpass123")); - } - - #[tokio::test] - async fn test_create_client_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_client_yaml(workspace_dir, "master", "testclient", true) - .await - .unwrap(); - - let file_path = workspace_dir - .join("master") - .join("clients") - .join("testclient.yaml"); - assert!(file_path.exists()); - - let content = fs::read_to_string(&file_path).await.unwrap(); - let client: ClientRepresentation = serde_yaml::from_str(&content).unwrap(); - - assert_eq!(client.client_id.as_deref(), Some("testclient")); - assert_eq!(client.public_client, Some(true)); - assert_eq!(client.service_accounts_enabled, Some(false)); - } - - #[tokio::test] - async fn test_create_role_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - // Realm role - create_role_yaml( - workspace_dir, - "master", - "admin", - Some("desc".to_string()), - None, - ) - .await - .unwrap(); - let realm_role_path = workspace_dir - .join("master") - .join("roles") - .join("admin.yaml"); - assert!(realm_role_path.exists()); - let content = fs::read_to_string(&realm_role_path).await.unwrap(); - let role: RoleRepresentation = serde_yaml::from_str(&content).unwrap(); - assert_eq!(role.name, "admin"); - assert_eq!(role.client_role, false); - - // Client role - create_role_yaml( - workspace_dir, - "master", - "editor", - None, - Some("my-client".to_string()), - ) - .await - .unwrap(); - let client_role_path = workspace_dir - .join("master") - .join("clients") - .join("my-client") - .join("roles") - .join("editor.yaml"); - assert!(client_role_path.exists()); - } - - #[tokio::test] - async fn test_create_group_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_group_yaml(workspace_dir, "master", "my-group") - .await - .unwrap(); - let file_path = workspace_dir - .join("master") - .join("groups") - .join("my-group.yaml"); - assert!(file_path.exists()); - } - - #[tokio::test] - async fn test_create_idp_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_idp_yaml(workspace_dir, "master", "google", "google") - .await - .unwrap(); - let file_path = workspace_dir - .join("master") - .join("identity-providers") - .join("google.yaml"); - assert!(file_path.exists()); - } - - #[tokio::test] - async fn test_create_client_scope_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - create_client_scope_yaml(workspace_dir, "master", "my-scope", "openid-connect") - .await - .unwrap(); - let file_path = workspace_dir - .join("master") - .join("client-scopes") - .join("my-scope.yaml"); - assert!(file_path.exists()); - } - - #[tokio::test] - async fn test_rotate_keys_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - let keys_dir = workspace_dir.join("master").join("components"); - fs::create_dir_all(&keys_dir).await.unwrap(); - - let original_component = ComponentRepresentation { - id: None, - name: Some("rsa-generated".to_string()), - provider_id: Some("rsa-generated".to_string()), - provider_type: Some("org.keycloak.keys.KeyProvider".to_string()), - parent_id: Some("master".to_string()), - sub_type: None, - config: Some({ - let mut map = HashMap::new(); - map.insert("priority".to_string(), serde_json::json!(["100"])); - map - }), - extra: HashMap::new(), - }; - - let original_yaml = serde_yaml::to_string(&original_component).unwrap(); - fs::write(keys_dir.join("rsa-generated.yaml"), original_yaml) - .await - .unwrap(); - - let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); - assert_eq!(count, 1); - - let mut entries = fs::read_dir(&keys_dir).await.unwrap(); - let mut found_rotated = false; - - while let Some(entry) = entries.next_entry().await.unwrap() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("rsa-generated-rotated-") { - found_rotated = true; - let content = fs::read_to_string(entry.path()).await.unwrap(); - let rotated: ComponentRepresentation = serde_yaml::from_str(&content).unwrap(); - - let config = rotated.config.unwrap(); - let priority_array = config.get("priority").unwrap().as_array().unwrap(); - assert_eq!(priority_array[0].as_str().unwrap(), "110"); - } - } - - assert!(found_rotated, "Did not find a rotated key component file"); - } - - #[tokio::test] - async fn test_rotate_keys_yaml_no_dir() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); - assert_eq!(count, 0); - } - - #[tokio::test] - async fn test_rotate_keys_yaml_no_yaml_files() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - let keys_dir = workspace_dir.join("master").join("components"); - fs::create_dir_all(&keys_dir).await.unwrap(); - fs::write(keys_dir.join("test.txt"), "not a yaml") - .await - .unwrap(); - - let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); - assert_eq!(count, 0); - } - - #[tokio::test] - async fn test_rotate_keys_yaml_invalid_priority() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - let keys_dir = workspace_dir.join("master").join("components"); - fs::create_dir_all(&keys_dir).await.unwrap(); - - let component = ComponentRepresentation { - id: None, - name: Some("rsa".to_string()), - provider_id: Some("rsa".to_string()), - provider_type: Some("org.keycloak.keys.KeyProvider".to_string()), - parent_id: Some("master".to_string()), - sub_type: None, - config: Some({ - let mut map = HashMap::new(); - map.insert("priority".to_string(), serde_json::json!(["invalid"])); - map - }), - extra: HashMap::new(), - }; - - let yaml = serde_yaml::to_string(&component).unwrap(); - fs::write(keys_dir.join("rsa.yaml"), yaml).await.unwrap(); - - let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); - assert_eq!(count, 1); // It still rotates, but priority won't be updated - - let mut entries = fs::read_dir(&keys_dir).await.unwrap(); - while let Some(entry) = entries.next_entry().await.unwrap() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("rsa-rotated-") { - let content = fs::read_to_string(entry.path()).await.unwrap(); - let rotated: ComponentRepresentation = serde_yaml::from_str(&content).unwrap(); - let config = rotated.config.unwrap(); - let priority_array = config.get("priority").unwrap().as_array().unwrap(); - assert_eq!(priority_array[0].as_str().unwrap(), "invalid"); - } - } - } - - #[tokio::test] - async fn test_change_user_password_yaml_new_user() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - - // Change password for a user that doesn't exist yet - change_user_password_yaml(workspace_dir, "master", "newuser", "pass123") - .await - .unwrap(); - - let file_path = workspace_dir - .join("master") - .join("users") - .join("newuser.yaml"); - assert!(file_path.exists()); - - let content = fs::read_to_string(&file_path).await.unwrap(); - let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); - let credentials = user.credentials.unwrap(); - assert_eq!(credentials[0].value.as_deref(), Some("pass123")); - } - - #[tokio::test] - async fn test_rotate_keys_yaml_not_key_provider() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - let keys_dir = workspace_dir.join("master").join("components"); - fs::create_dir_all(&keys_dir).await.unwrap(); - - let component = ComponentRepresentation { - id: None, - name: Some("not-key".to_string()), - provider_id: Some("not-key".to_string()), - provider_type: Some("something.else".to_string()), - parent_id: Some("master".to_string()), - sub_type: None, - config: None, - extra: HashMap::new(), - }; - - let yaml = serde_yaml::to_string(&component).unwrap(); - fs::write(keys_dir.join("not-key.yaml"), yaml) - .await - .unwrap(); - - let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); - assert_eq!(count, 0); - } - - #[tokio::test] - async fn test_change_user_password_yaml_invalid_yaml() { - let dir = tempdir().unwrap(); - let workspace_dir = dir.path(); - let user_path = workspace_dir - .join("master") - .join("users") - .join("baduser.yaml"); - fs::create_dir_all(user_path.parent().unwrap()) - .await - .unwrap(); - fs::write(&user_path, "not a yaml : [ :").await.unwrap(); - - let res = change_user_password_yaml(workspace_dir, "master", "baduser", "newpass").await; - assert!(res.is_err()); - } -} - -async fn create_client_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let client_id: String = Input::with_theme(&theme) - .with_prompt("Client ID") - .interact_text()?; - - let is_public = Confirm::with_theme(&theme) - .with_prompt("Is this a public client? (No for confidential)") - .default(true) - .interact()?; - - create_client_yaml(workspace_dir, &realm, &client_id, is_public).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for client '{}' in realm '{}'.", - client_id, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_client_yaml( - workspace_dir: &Path, - realm: &str, - client_id: &str, - is_public: bool, -) -> Result<()> { - let client = ClientRepresentation { - id: None, - client_id: Some(client_id.to_string()), - name: None, - description: None, - enabled: Some(true), - protocol: Some("openid-connect".to_string()), - redirect_uris: Some(vec!["/*".to_string()]), - web_origins: Some(vec!["+".to_string()]), - public_client: Some(is_public), - bearer_only: Some(false), - service_accounts_enabled: Some(!is_public), - extra: HashMap::new(), - }; - - let realm_dir = workspace_dir.join(realm).join("clients"); - fs::create_dir_all(&realm_dir) - .await - .context("Failed to create clients directory")?; - - let file_path = realm_dir.join(format!("{}.yaml", client_id)); - let yaml = serde_yaml::to_string(&client).context("Failed to serialize client to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write client YAML file")?; - - Ok(()) -} - -async fn change_user_password_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let username: String = Input::with_theme(&theme) - .with_prompt("Username") - .interact_text()?; - - let new_password = Password::with_theme(&theme) - .with_prompt("New Password") - .with_confirmation("Confirm Password", "Passwords mismatching") - .interact()?; - - change_user_password_yaml(workspace_dir, &realm, &username, &new_password).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully updated YAML for user '{}' in realm '{}' with new password.", - username, realm - )) - .green() - ); - Ok(()) -} - -pub async fn change_user_password_yaml( - workspace_dir: &Path, - realm: &str, - username: &str, - new_password: &str, -) -> Result<()> { - let file_path = workspace_dir - .join(realm) - .join("users") - .join(format!("{}.yaml", username)); - - if !file_path.exists() { - println!( - "{} {}", - WARN, - style(format!( - "Warning: User file {:?} does not exist. Creating a new one.", - file_path - )) - .yellow() - ); - create_user_yaml(workspace_dir, realm, username, None, None, None).await?; - } - - let yaml_content = fs::read_to_string(&file_path) - .await - .context("Failed to read user YAML file")?; - let mut user: UserRepresentation = - serde_yaml::from_str(&yaml_content).context("Failed to parse user YAML file")?; - - let new_cred = CredentialRepresentation { - id: None, - type_: Some("password".to_string()), - value: Some(new_password.to_string()), - temporary: Some(false), - extra: HashMap::new(), - }; - - if let Some(credentials) = &mut user.credentials { - if let Some(existing) = credentials - .iter_mut() - .find(|c| c.type_.as_deref() == Some("password")) - { - existing.value = Some(new_password.to_string()); - } else { - credentials.push(new_cred); - } - } else { - user.credentials = Some(vec![new_cred]); - } - - let yaml = serde_yaml::to_string(&user).context("Failed to serialize user to YAML")?; - fs::write(&file_path, yaml) - .await - .context("Failed to write updated user YAML file")?; - - Ok(()) -} - -async fn create_user_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let username: String = Input::with_theme(&theme) - .with_prompt("Username") - .interact_text()?; - - let email: String = Input::with_theme(&theme) - .with_prompt("Email") - .allow_empty(true) - .interact_text()?; - - let first_name: String = Input::with_theme(&theme) - .with_prompt("First Name") - .allow_empty(true) - .interact_text()?; - - let last_name: String = Input::with_theme(&theme) - .with_prompt("Last Name") - .allow_empty(true) - .interact_text()?; - - let email_opt = if email.is_empty() { None } else { Some(email) }; - let first_name_opt = if first_name.is_empty() { - None - } else { - Some(first_name) - }; - let last_name_opt = if last_name.is_empty() { - None - } else { - Some(last_name) - }; - - create_user_yaml( - workspace_dir, - &realm, - &username, - email_opt, - first_name_opt, - last_name_opt, - ) - .await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for user '{}' in realm '{}'.", - username, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_user_yaml( - workspace_dir: &Path, - realm: &str, - username: &str, - email: Option, - first_name: Option, - last_name: Option, -) -> Result<()> { - let user = UserRepresentation { - id: None, - username: Some(username.to_string()), - enabled: Some(true), - first_name, - last_name, - email, - email_verified: Some(false), - credentials: None, - extra: HashMap::new(), - }; - - let realm_dir = workspace_dir.join(realm).join("users"); - fs::create_dir_all(&realm_dir) - .await - .context("Failed to create users directory")?; - - let file_path = realm_dir.join(format!("{}.yaml", username)); - let yaml = serde_yaml::to_string(&user).context("Failed to serialize user to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write user YAML file")?; - - Ok(()) -} diff --git a/src/cli/client.rs b/src/cli/client.rs new file mode 100644 index 0000000..24e9f79 --- /dev/null +++ b/src/cli/client.rs @@ -0,0 +1,178 @@ +use super::SUCCESS; +use crate::models::{ClientRepresentation, ClientScopeRepresentation}; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Confirm, Input, theme::ColorfulTheme}; +use std::collections::HashMap; +use std::path::Path; +use tokio::fs; + +pub async fn create_client_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let client_id: String = Input::with_theme(&theme) + .with_prompt("Client ID") + .interact_text()?; + + let is_public = Confirm::with_theme(&theme) + .with_prompt("Is this a public client? (No for confidential)") + .default(true) + .interact()?; + + create_client_yaml(workspace_dir, &realm, &client_id, is_public).await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated YAML for client '{}' in realm '{}'.", + client_id, realm + )) + .green() + ); + Ok(()) +} + +pub async fn create_client_yaml( + workspace_dir: &Path, + realm: &str, + client_id: &str, + is_public: bool, +) -> Result<()> { + let client = ClientRepresentation { + id: None, + client_id: Some(client_id.to_string()), + name: None, + description: None, + enabled: Some(true), + protocol: Some("openid-connect".to_string()), + redirect_uris: Some(vec!["/*".to_string()]), + web_origins: Some(vec!["+".to_string()]), + public_client: Some(is_public), + bearer_only: Some(false), + service_accounts_enabled: Some(!is_public), + extra: HashMap::new(), + }; + + let realm_dir = workspace_dir.join(realm).join("clients"); + fs::create_dir_all(&realm_dir) + .await + .context("Failed to create clients directory")?; + + let file_path = realm_dir.join(format!("{}.yaml", client_id)); + let yaml = serde_yaml::to_string(&client).context("Failed to serialize client to YAML")?; + + fs::write(&file_path, yaml) + .await + .context("Failed to write client YAML file")?; + + Ok(()) +} + +pub async fn create_client_scope_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let name: String = Input::with_theme(&theme) + .with_prompt("Scope Name") + .interact_text()?; + + let protocol: String = Input::with_theme(&theme) + .with_prompt("Protocol") + .default("openid-connect".to_string()) + .interact_text()?; + + create_client_scope_yaml(workspace_dir, &realm, &name, &protocol).await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated YAML for client scope '{}' in realm '{}'.", + name, realm + )) + .green() + ); + Ok(()) +} + +pub async fn create_client_scope_yaml( + workspace_dir: &Path, + realm: &str, + name: &str, + protocol: &str, +) -> Result<()> { + let scope = ClientScopeRepresentation { + id: None, + name: Some(name.to_string()), + description: None, + protocol: Some(protocol.to_string()), + attributes: Some(HashMap::new()), + extra: HashMap::new(), + }; + + let scopes_dir = workspace_dir.join(realm).join("client-scopes"); + fs::create_dir_all(&scopes_dir) + .await + .context("Failed to create client-scopes directory")?; + + let file_path = scopes_dir.join(format!("{}.yaml", name)); + let yaml = serde_yaml::to_string(&scope).context("Failed to serialize client scope to YAML")?; + + fs::write(&file_path, yaml) + .await + .context("Failed to write client scope YAML file")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_client_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_client_yaml(workspace_dir, "master", "testclient", true) + .await + .unwrap(); + + let file_path = workspace_dir + .join("master") + .join("clients") + .join("testclient.yaml"); + assert!(file_path.exists()); + + let content = fs::read_to_string(&file_path).await.unwrap(); + let client: ClientRepresentation = serde_yaml::from_str(&content).unwrap(); + + assert_eq!(client.client_id.as_deref(), Some("testclient")); + assert_eq!(client.public_client, Some(true)); + assert_eq!(client.service_accounts_enabled, Some(false)); + } + + #[tokio::test] + async fn test_create_client_scope_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_client_scope_yaml(workspace_dir, "master", "my-scope", "openid-connect") + .await + .unwrap(); + let file_path = workspace_dir + .join("master") + .join("client-scopes") + .join("my-scope.yaml"); + assert!(file_path.exists()); + } +} diff --git a/src/cli/group.rs b/src/cli/group.rs new file mode 100644 index 0000000..70aa51c --- /dev/null +++ b/src/cli/group.rs @@ -0,0 +1,78 @@ +use super::SUCCESS; +use crate::models::GroupRepresentation; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Input, theme::ColorfulTheme}; +use std::collections::HashMap; +use std::path::Path; +use tokio::fs; + +pub async fn create_group_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let name: String = Input::with_theme(&theme) + .with_prompt("Group Name") + .interact_text()?; + + create_group_yaml(workspace_dir, &realm, &name).await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated YAML for group '{}' in realm '{}'.", + name, realm + )) + .green() + ); + Ok(()) +} + +pub async fn create_group_yaml(workspace_dir: &Path, realm: &str, name: &str) -> Result<()> { + let group = GroupRepresentation { + id: None, + name: Some(name.to_string()), + path: None, + sub_groups: None, + extra: HashMap::new(), + }; + + let groups_dir = workspace_dir.join(realm).join("groups"); + fs::create_dir_all(&groups_dir) + .await + .context("Failed to create groups directory")?; + + let file_path = groups_dir.join(format!("{}.yaml", name)); + let yaml = serde_yaml::to_string(&group).context("Failed to serialize group to YAML")?; + + fs::write(&file_path, yaml) + .await + .context("Failed to write group YAML file")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_group_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_group_yaml(workspace_dir, "master", "my-group") + .await + .unwrap(); + let file_path = workspace_dir + .join("master") + .join("groups") + .join("my-group.yaml"); + assert!(file_path.exists()); + } +} diff --git a/src/cli/idp.rs b/src/cli/idp.rs new file mode 100644 index 0000000..0967e08 --- /dev/null +++ b/src/cli/idp.rs @@ -0,0 +1,98 @@ +use super::SUCCESS; +use crate::models::IdentityProviderRepresentation; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Input, theme::ColorfulTheme}; +use std::collections::HashMap; +use std::path::Path; +use tokio::fs; + +pub async fn create_idp_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let alias: String = Input::with_theme(&theme) + .with_prompt("Alias (e.g., google)") + .interact_text()?; + + let provider_id: String = Input::with_theme(&theme) + .with_prompt("Provider ID (e.g., google, github, oidc)") + .default(alias.clone()) + .interact_text()?; + + create_idp_yaml(workspace_dir, &realm, &alias, &provider_id).await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated YAML for Identity Provider '{}' in realm '{}'.", + alias, realm + )) + .green() + ); + Ok(()) +} + +pub async fn create_idp_yaml( + workspace_dir: &Path, + realm: &str, + alias: &str, + provider_id: &str, +) -> Result<()> { + let idp = IdentityProviderRepresentation { + internal_id: None, + alias: Some(alias.to_string()), + provider_id: Some(provider_id.to_string()), + enabled: Some(true), + update_profile_first_login_mode: Some("on".to_string()), + trust_email: Some(false), + store_token: Some(false), + add_read_token_role_on_create: Some(false), + authenticate_by_default: Some(false), + link_only: Some(false), + first_broker_login_flow_alias: Some("first broker login".to_string()), + post_broker_login_flow_alias: None, + display_name: Some(alias.to_string()), + config: Some(HashMap::new()), + extra: HashMap::new(), + }; + + let idp_dir = workspace_dir.join(realm).join("identity-providers"); + fs::create_dir_all(&idp_dir) + .await + .context("Failed to create identity-providers directory")?; + + let file_path = idp_dir.join(format!("{}.yaml", alias)); + let yaml = serde_yaml::to_string(&idp).context("Failed to serialize IDP to YAML")?; + + fs::write(&file_path, yaml) + .await + .context("Failed to write IDP YAML file")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_idp_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_idp_yaml(workspace_dir, "master", "google", "google") + .await + .unwrap(); + let file_path = workspace_dir + .join("master") + .join("identity-providers") + .join("google.yaml"); + assert!(file_path.exists()); + } +} diff --git a/src/cli/keys.rs b/src/cli/keys.rs new file mode 100644 index 0000000..207e06a --- /dev/null +++ b/src/cli/keys.rs @@ -0,0 +1,266 @@ +use super::{INFO, SUCCESS}; +use crate::models::ComponentRepresentation; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Input, theme::ColorfulTheme}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::fs; + +pub async fn rotate_keys_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let rotated_count = rotate_keys_yaml(workspace_dir, &realm).await?; + + if rotated_count > 0 { + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated {} rotated key component(s) for realm '{}'.", + rotated_count, realm + )) + .green() + ); + } else { + println!( + "{} {}", + INFO, + style(format!( + "No key providers found to rotate for realm '{}'.", + realm + )) + .cyan() + ); + } + + Ok(()) +} + +pub async fn rotate_keys_yaml(workspace_dir: &Path, realm: &str) -> Result { + let keys_dir = workspace_dir.join(realm).join("components"); + + if !tokio::fs::try_exists(&keys_dir).await.unwrap_or(false) { + return Ok(0); + } + + let mut rotated_count = 0; + let mut entries = fs::read_dir(&keys_dir) + .await + .context("Failed to read components directory")?; + + while let Some(entry) = entries + .next_entry() + .await + .context("Failed to read directory entry")? + { + let path = entry.path(); + if path.is_file() + && path + .extension() + .is_some_and(|ext| ext == "yaml" || ext == "yml") + { + let yaml_content = fs::read_to_string(&path) + .await + .context("Failed to read key YAML file")?; + + #[allow(clippy::collapsible_if)] + if let Ok(component) = serde_yaml::from_str::(&yaml_content) { + if component.provider_type.as_deref() == Some("org.keycloak.keys.KeyProvider") { + let mut new_component = component.clone(); + new_component.id = None; + + let old_name = new_component + .name + .clone() + .unwrap_or_else(|| "key".to_string()); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX EPOCH")? + .as_secs(); + new_component.name = Some(format!("{}-rotated-{}", old_name, timestamp)); + + #[allow(clippy::collapsible_if)] + if let Some(config) = &mut new_component.config { + if let Some(priority_vals) = config.get_mut("priority") { + if let Some(arr) = priority_vals.as_array_mut() { + if let Some(first) = arr.first_mut() { + if let Some(p_str) = first.as_str() { + if let Ok(p_num) = p_str.parse::() { + *first = + serde_json::Value::String((p_num + 10).to_string()); + } + } + } + } + } + } + + let new_filename = format!("{}.yaml", new_component.name.as_deref().unwrap()); + let new_file_path = keys_dir.join(new_filename); + + let yaml = serde_yaml::to_string(&new_component) + .context("Failed to serialize rotated key to YAML")?; + fs::write(&new_file_path, yaml) + .await + .context("Failed to write rotated key YAML file")?; + + rotated_count += 1; + } + } + } + } + + Ok(rotated_count) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::tempdir; + + #[tokio::test] + async fn test_rotate_keys_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + let keys_dir = workspace_dir.join("master").join("components"); + fs::create_dir_all(&keys_dir).await.unwrap(); + + let original_component = ComponentRepresentation { + id: None, + name: Some("rsa-generated".to_string()), + provider_id: Some("rsa-generated".to_string()), + provider_type: Some("org.keycloak.keys.KeyProvider".to_string()), + parent_id: Some("master".to_string()), + sub_type: None, + config: Some({ + let mut map = HashMap::new(); + map.insert("priority".to_string(), serde_json::json!(["100"])); + map + }), + extra: HashMap::new(), + }; + + let original_yaml = serde_yaml::to_string(&original_component).unwrap(); + fs::write(keys_dir.join("rsa-generated.yaml"), original_yaml) + .await + .unwrap(); + + let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); + assert_eq!(count, 1); + + let mut entries = fs::read_dir(&keys_dir).await.unwrap(); + let mut found_rotated = false; + + while let Some(entry) = entries.next_entry().await.unwrap() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("rsa-generated-rotated-") { + found_rotated = true; + let content = fs::read_to_string(entry.path()).await.unwrap(); + let rotated: ComponentRepresentation = serde_yaml::from_str(&content).unwrap(); + + let config = rotated.config.unwrap(); + let priority_array = config.get("priority").unwrap().as_array().unwrap(); + assert_eq!(priority_array[0].as_str().unwrap(), "110"); + } + } + + assert!(found_rotated, "Did not find a rotated key component file"); + } + + #[tokio::test] + async fn test_rotate_keys_yaml_no_dir() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_rotate_keys_yaml_no_yaml_files() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let keys_dir = workspace_dir.join("master").join("components"); + fs::create_dir_all(&keys_dir).await.unwrap(); + fs::write(keys_dir.join("test.txt"), "not a yaml") + .await + .unwrap(); + + let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_rotate_keys_yaml_invalid_priority() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let keys_dir = workspace_dir.join("master").join("components"); + fs::create_dir_all(&keys_dir).await.unwrap(); + + let component = ComponentRepresentation { + id: None, + name: Some("rsa".to_string()), + provider_id: Some("rsa".to_string()), + provider_type: Some("org.keycloak.keys.KeyProvider".to_string()), + parent_id: Some("master".to_string()), + sub_type: None, + config: Some({ + let mut map = HashMap::new(); + map.insert("priority".to_string(), serde_json::json!(["invalid"])); + map + }), + extra: HashMap::new(), + }; + + let yaml = serde_yaml::to_string(&component).unwrap(); + fs::write(keys_dir.join("rsa.yaml"), yaml).await.unwrap(); + + let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); + assert_eq!(count, 1); // It still rotates, but priority won't be updated + + let mut entries = fs::read_dir(&keys_dir).await.unwrap(); + while let Some(entry) = entries.next_entry().await.unwrap() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("rsa-rotated-") { + let content = fs::read_to_string(entry.path()).await.unwrap(); + let rotated: ComponentRepresentation = serde_yaml::from_str(&content).unwrap(); + let config = rotated.config.unwrap(); + let priority_array = config.get("priority").unwrap().as_array().unwrap(); + assert_eq!(priority_array[0].as_str().unwrap(), "invalid"); + } + } + } + + #[tokio::test] + async fn test_rotate_keys_yaml_not_key_provider() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let keys_dir = workspace_dir.join("master").join("components"); + fs::create_dir_all(&keys_dir).await.unwrap(); + + let component = ComponentRepresentation { + id: None, + name: Some("not-key".to_string()), + provider_id: Some("not-key".to_string()), + provider_type: Some("something.else".to_string()), + parent_id: Some("master".to_string()), + sub_type: None, + config: None, + extra: HashMap::new(), + }; + + let yaml = serde_yaml::to_string(&component).unwrap(); + fs::write(keys_dir.join("not-key.yaml"), yaml) + .await + .unwrap(); + + let count = rotate_keys_yaml(workspace_dir, "master").await.unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..1189511 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,126 @@ +pub mod client; +pub mod group; +pub mod idp; +pub mod keys; +pub mod role; +pub mod user; + +use anyhow::Result; +use console::{Emoji, style}; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::path::PathBuf; + +pub static SUCCESS: Emoji<'_, '_> = Emoji("✨ ", "* "); +pub static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); +pub static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); +pub static INFO: Emoji<'_, '_> = Emoji("💡 ", "i "); + +pub async fn run(workspace_dir: PathBuf) -> Result<()> { + println!( + "{} {}", + INFO, + style("Welcome to kcd interactive CLI!").cyan().bold() + ); + let theme = ColorfulTheme::default(); + let selections = &[ + "Create User", + "Change User Password", + "Create Client", + "Create Role", + "Create Group", + "Create Identity Provider", + "Create Client Scope", + "Rotate Keys", + "Exit", + ]; + + loop { + let selection = Select::with_theme(&theme) + .with_prompt("What would you like to do?") + .default(0) + .items(&selections[..]) + .interact()?; + + match selection { + 0 => { + if let Err(e) = user::create_user_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error creating user: {}", e)).red() + ); + } + } + 1 => { + if let Err(e) = user::change_user_password_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error changing password: {}", e)).red() + ); + } + } + 2 => { + if let Err(e) = client::create_client_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error creating client: {}", e)).red() + ); + } + } + 3 => { + if let Err(e) = role::create_role_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error creating role: {}", e)).red() + ); + } + } + 4 => { + if let Err(e) = group::create_group_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error creating group: {}", e)).red() + ); + } + } + 5 => { + if let Err(e) = idp::create_idp_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error creating IDP: {}", e)).red() + ); + } + } + 6 => { + if let Err(e) = client::create_client_scope_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error creating client scope: {}", e)).red() + ); + } + } + 7 => { + if let Err(e) = keys::rotate_keys_interactive(&workspace_dir).await { + println!( + "{} {}", + ERROR, + style(format!("Error rotating keys: {}", e)).red() + ); + } + } + 8 => { + println!("{} {}", INFO, style("Exiting...").cyan()); + break; + } + _ => unreachable!(), + } + } + + Ok(()) +} diff --git a/src/cli/role.rs b/src/cli/role.rs new file mode 100644 index 0000000..172551f --- /dev/null +++ b/src/cli/role.rs @@ -0,0 +1,146 @@ +use super::SUCCESS; +use crate::models::RoleRepresentation; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Confirm, Input, theme::ColorfulTheme}; +use std::collections::HashMap; +use std::path::Path; +use tokio::fs; + +pub async fn create_role_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let name: String = Input::with_theme(&theme) + .with_prompt("Role Name") + .interact_text()?; + + let description: String = Input::with_theme(&theme) + .with_prompt("Description") + .allow_empty(true) + .interact_text()?; + + let is_client_role = Confirm::with_theme(&theme) + .with_prompt("Is this a client role?") + .default(false) + .interact()?; + + let client_id = if is_client_role { + let id: String = Input::with_theme(&theme) + .with_prompt("Client ID") + .interact_text()?; + Some(id) + } else { + None + }; + + let description_opt = if description.is_empty() { + None + } else { + Some(description) + }; + + create_role_yaml(workspace_dir, &realm, &name, description_opt, client_id).await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated YAML for role '{}' in realm '{}'.", + name, realm + )) + .green() + ); + Ok(()) +} + +pub async fn create_role_yaml( + workspace_dir: &Path, + realm: &str, + name: &str, + description: Option, + client_id: Option, +) -> Result<()> { + let role = RoleRepresentation { + id: None, + name: name.to_string(), + description, + container_id: None, + composite: false, + client_role: client_id.is_some(), + extra: HashMap::new(), + }; + + let realm_dir = workspace_dir.join(realm); + let roles_dir = if let Some(cid) = &client_id { + realm_dir.join("clients").join(cid).join("roles") + } else { + realm_dir.join("roles") + }; + + fs::create_dir_all(&roles_dir) + .await + .context("Failed to create roles directory")?; + + let file_path = roles_dir.join(format!("{}.yaml", name)); + let yaml = serde_yaml::to_string(&role).context("Failed to serialize role to YAML")?; + + fs::write(&file_path, yaml) + .await + .context("Failed to write role YAML file")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_role_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + // Realm role + create_role_yaml( + workspace_dir, + "master", + "admin", + Some("desc".to_string()), + None, + ) + .await + .unwrap(); + let realm_role_path = workspace_dir + .join("master") + .join("roles") + .join("admin.yaml"); + assert!(realm_role_path.exists()); + let content = fs::read_to_string(&realm_role_path).await.unwrap(); + let role: RoleRepresentation = serde_yaml::from_str(&content).unwrap(); + assert_eq!(role.name, "admin"); + assert_eq!(role.client_role, false); + + // Client role + create_role_yaml( + workspace_dir, + "master", + "editor", + None, + Some("my-client".to_string()), + ) + .await + .unwrap(); + let client_role_path = workspace_dir + .join("master") + .join("clients") + .join("my-client") + .join("roles") + .join("editor.yaml"); + assert!(client_role_path.exists()); + } +} diff --git a/src/cli/user.rs b/src/cli/user.rs new file mode 100644 index 0000000..0e1102f --- /dev/null +++ b/src/cli/user.rs @@ -0,0 +1,394 @@ +use super::{SUCCESS, WARN}; +use crate::models::{CredentialRepresentation, UserRepresentation}; +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{Input, Password, theme::ColorfulTheme}; +use std::collections::HashMap; +use std::path::Path; +use tokio::fs; + +pub async fn change_user_password_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let username: String = Input::with_theme(&theme) + .with_prompt("Username") + .interact_text()?; + + let new_password = Password::with_theme(&theme) + .with_prompt("New Password") + .with_confirmation("Confirm Password", "Passwords mismatching") + .interact()?; + + change_user_password_yaml(workspace_dir, &realm, &username, &new_password).await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully updated YAML for user '{}' in realm '{}' with new password.", + username, realm + )) + .green() + ); + Ok(()) +} + +pub async fn change_user_password_yaml( + workspace_dir: &Path, + realm: &str, + username: &str, + new_password: &str, +) -> Result<()> { + let file_path = workspace_dir + .join(realm) + .join("users") + .join(format!("{}.yaml", username)); + + if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) { + println!( + "{} {}", + WARN, + style(format!( + "Warning: User file {:?} does not exist. Creating a new one.", + file_path + )) + .yellow() + ); + create_user_yaml(workspace_dir, realm, username, None, None, None).await?; + } + + let yaml_content = fs::read_to_string(&file_path) + .await + .context("Failed to read user YAML file")?; + let mut user: UserRepresentation = + serde_yaml::from_str(&yaml_content).context("Failed to parse user YAML file")?; + + let new_cred = CredentialRepresentation { + id: None, + type_: Some("password".to_string()), + value: Some(new_password.to_string()), + temporary: Some(false), + extra: HashMap::new(), + }; + + if let Some(credentials) = &mut user.credentials { + if let Some(existing) = credentials + .iter_mut() + .find(|c| c.type_.as_deref() == Some("password")) + { + existing.value = Some(new_password.to_string()); + } else { + credentials.push(new_cred); + } + } else { + user.credentials = Some(vec![new_cred]); + } + + let yaml = serde_yaml::to_string(&user).context("Failed to serialize user to YAML")?; + fs::write(&file_path, yaml) + .await + .context("Failed to write updated user YAML file")?; + + Ok(()) +} + +pub async fn create_user_interactive(workspace_dir: &Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + let realm: String = Input::with_theme(&theme) + .with_prompt("Target Realm") + .interact_text()?; + + let username: String = Input::with_theme(&theme) + .with_prompt("Username") + .interact_text()?; + + let email: String = Input::with_theme(&theme) + .with_prompt("Email") + .allow_empty(true) + .interact_text()?; + + let first_name: String = Input::with_theme(&theme) + .with_prompt("First Name") + .allow_empty(true) + .interact_text()?; + + let last_name: String = Input::with_theme(&theme) + .with_prompt("Last Name") + .allow_empty(true) + .interact_text()?; + + let email_opt = if email.is_empty() { None } else { Some(email) }; + let first_name_opt = if first_name.is_empty() { + None + } else { + Some(first_name) + }; + let last_name_opt = if last_name.is_empty() { + None + } else { + Some(last_name) + }; + + create_user_yaml( + workspace_dir, + &realm, + &username, + email_opt, + first_name_opt, + last_name_opt, + ) + .await?; + + println!( + "{} {}", + SUCCESS, + style(format!( + "Successfully generated YAML for user '{}' in realm '{}'.", + username, realm + )) + .green() + ); + Ok(()) +} + +pub async fn create_user_yaml( + workspace_dir: &Path, + realm: &str, + username: &str, + email: Option, + first_name: Option, + last_name: Option, +) -> Result<()> { + let user = UserRepresentation { + id: None, + username: Some(username.to_string()), + enabled: Some(true), + first_name, + last_name, + email, + email_verified: Some(false), + credentials: None, + extra: HashMap::new(), + }; + + let realm_dir = workspace_dir.join(realm).join("users"); + fs::create_dir_all(&realm_dir) + .await + .context("Failed to create users directory")?; + + let file_path = realm_dir.join(format!("{}.yaml", username)); + let yaml = serde_yaml::to_string(&user).context("Failed to serialize user to YAML")?; + + fs::write(&file_path, yaml) + .await + .context("Failed to write user YAML file")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_user_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_user_yaml( + workspace_dir, + "master", + "testuser", + Some("test@example.com".to_string()), + Some("Test".to_string()), + Some("User".to_string()), + ) + .await + .unwrap(); + + let file_path = workspace_dir + .join("master") + .join("users") + .join("testuser.yaml"); + assert!(file_path.exists()); + + let content = fs::read_to_string(&file_path).await.unwrap(); + let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); + + assert_eq!(user.username.as_deref(), Some("testuser")); + assert_eq!(user.email.as_deref(), Some("test@example.com")); + assert_eq!(user.first_name.as_deref(), Some("Test")); + assert_eq!(user.last_name.as_deref(), Some("User")); + } + + #[tokio::test] + async fn test_create_user_yaml_partial() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_user_yaml(workspace_dir, "master", "user2", None, None, None) + .await + .unwrap(); + + let file_path = workspace_dir + .join("master") + .join("users") + .join("user2.yaml"); + let content = fs::read_to_string(&file_path).await.unwrap(); + let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); + + assert_eq!(user.username.as_deref(), Some("user2")); + assert_eq!(user.email, None); + assert_eq!(user.first_name, None); + assert_eq!(user.last_name, None); + } + + #[tokio::test] + async fn test_change_user_password_yaml_existing_password() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_user_yaml(workspace_dir, "master", "testuser", None, None, None) + .await + .unwrap(); + + // Add first password + change_user_password_yaml(workspace_dir, "master", "testuser", "pass1") + .await + .unwrap(); + + // Change password (should update existing) + change_user_password_yaml(workspace_dir, "master", "testuser", "pass2") + .await + .unwrap(); + + let file_path = workspace_dir + .join("master") + .join("users") + .join("testuser.yaml"); + let content = fs::read_to_string(&file_path).await.unwrap(); + let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); + + let credentials = user.credentials.unwrap(); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].value.as_deref(), Some("pass2")); + } + + #[tokio::test] + async fn test_change_user_password_yaml_with_other_credentials() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + let user = UserRepresentation { + id: None, + username: Some("testuser".to_string()), + enabled: Some(true), + first_name: None, + last_name: None, + email: None, + email_verified: None, + credentials: Some(vec![CredentialRepresentation { + id: None, + type_: Some("otp".to_string()), + value: Some("secret".to_string()), + temporary: Some(false), + extra: HashMap::new(), + }]), + extra: HashMap::new(), + }; + + let realm_dir = workspace_dir.join("master").join("users"); + fs::create_dir_all(&realm_dir).await.unwrap(); + let file_path = realm_dir.join("testuser.yaml"); + let yaml = serde_yaml::to_string(&user).unwrap(); + fs::write(&file_path, yaml).await.unwrap(); + + change_user_password_yaml(workspace_dir, "master", "testuser", "newpass") + .await + .unwrap(); + + let content = fs::read_to_string(&file_path).await.unwrap(); + let updated_user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); + let credentials = updated_user.credentials.unwrap(); + assert_eq!(credentials.len(), 2); + assert!( + credentials + .iter() + .any(|c| c.type_.as_deref() == Some("otp")) + ); + assert!(credentials.iter().any( + |c| c.type_.as_deref() == Some("password") && c.value.as_deref() == Some("newpass") + )); + } + + #[tokio::test] + async fn test_change_user_password_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + create_user_yaml(workspace_dir, "master", "testuser", None, None, None) + .await + .unwrap(); + + change_user_password_yaml(workspace_dir, "master", "testuser", "newpass123") + .await + .unwrap(); + + let file_path = workspace_dir + .join("master") + .join("users") + .join("testuser.yaml"); + let content = fs::read_to_string(&file_path).await.unwrap(); + let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); + + let credentials = user.credentials.expect("Credentials should not be None"); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].type_.as_deref(), Some("password")); + assert_eq!(credentials[0].value.as_deref(), Some("newpass123")); + } + + #[tokio::test] + async fn test_change_user_password_yaml_new_user() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + + // Change password for a user that doesn't exist yet + change_user_password_yaml(workspace_dir, "master", "newuser", "pass123") + .await + .unwrap(); + + let file_path = workspace_dir + .join("master") + .join("users") + .join("newuser.yaml"); + assert!(file_path.exists()); + + let content = fs::read_to_string(&file_path).await.unwrap(); + let user: UserRepresentation = serde_yaml::from_str(&content).unwrap(); + let credentials = user.credentials.unwrap(); + assert_eq!(credentials[0].value.as_deref(), Some("pass123")); + } + + #[tokio::test] + async fn test_change_user_password_yaml_invalid_yaml() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let user_path = workspace_dir + .join("master") + .join("users") + .join("baduser.yaml"); + fs::create_dir_all(user_path.parent().unwrap()) + .await + .unwrap(); + fs::write(&user_path, "not a yaml : [ :").await.unwrap(); + + let res = change_user_password_yaml(workspace_dir, "master", "baduser", "newpass").await; + assert!(res.is_err()); + } +} diff --git a/src/inspect.rs b/src/inspect.rs index 5cb1603..b89a89a 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -99,36 +99,6 @@ pub async fn run( Ok(()) } -async fn write_if_changed(path: &Path, content: &str, yes: bool) -> Result<()> { - if fs::try_exists(path).await.unwrap_or(false) { - let existing = fs::read_to_string(path).await.unwrap_or_default(); - if existing == content { - return Ok(()); - } - - if !yes - && !Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(format!( - "File {:?} already exists with different content. Overwrite?", - path - )) - .default(false) - .interact()? - { - println!( - "{} {}", - WARN, - style(format!("Skipping {:?}", path)).yellow() - ); - return Ok(()); - } - } - fs::write(path, content) - .await - .context(format!("Failed to write {:?}", path))?; - Ok(()) -} - async fn write_if_changed_with_mutex( path: &Path, content: &str, @@ -183,408 +153,515 @@ async fn inspect_realm( .context("Failed to create output directory")?; } - // Fetch realm - let realm = client.get_realm().await.context("Failed to fetch realm")?; - let mut local_secrets = HashMap::new(); - let realm_prefix = format!("realm_{}", realm_name); - let realm_yaml = to_sorted_yaml_with_secrets(&realm, &realm_prefix, &mut local_secrets) - .context("Failed to serialize realm")?; - all_secrets.lock().await.extend(local_secrets); + let mut master_set = tokio::task::JoinSet::new(); - let realm_path = workspace_dir.join("realm.yaml"); - write_if_changed(&realm_path, &realm_yaml, yes).await?; - println!( - " {} {}", - SUCCESS, - style("Exported realm configuration to realm.yaml").green() - ); - - // Fetch clients - let clients = client - .get_clients() - .await - .context("Failed to fetch clients")?; - let clients_dir = workspace_dir.join("clients"); - if !fs::try_exists(&clients_dir) - .await - .context("Failed to check clients directory")? + // Fetch realm configuration { - fs::create_dir_all(&clients_dir) - .await - .context("Failed to create clients directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for client_rep in clients { - let clients_dir = clients_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = client_rep.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = clients_dir.join(filename); + master_set.spawn(async move { + let realm = client.get_realm().await.context("Failed to fetch realm")?; let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_client", realm_name); - let yaml = to_sorted_yaml_with_secrets(&client_rep, &prefix, &mut local_secrets) - .context("Failed to serialize client")?; + let realm_prefix = format!("realm_{}", realm_name); + let realm_yaml = to_sorted_yaml_with_secrets(&realm, &realm_prefix, &mut local_secrets) + .context("Failed to serialize realm")?; all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + + let realm_path = workspace_dir.join("realm.yaml"); + write_if_changed_with_mutex(&realm_path, &realm_yaml, yes, prompt_mutex).await?; + println!( + " {} {}", + SUCCESS, + style("Exported realm configuration to realm.yaml").green() + ); + Ok::<(), anyhow::Error>(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; + + // Fetch clients + { + let client = client.clone(); + let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let prompt_mutex = Arc::clone(&prompt_mutex); + master_set.spawn(async move { + let clients = client + .get_clients() + .await + .context("Failed to fetch clients")?; + let clients_dir = workspace_dir.join("clients"); + if !fs::try_exists(&clients_dir) + .await + .context("Failed to check clients directory")? + { + fs::create_dir_all(&clients_dir) + .await + .context("Failed to create clients directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for client_rep in clients { + let clients_dir = clients_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = client_rep.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = clients_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_client", realm_name); + let yaml = + to_sorted_yaml_with_secrets(&client_rep, &prefix, &mut local_secrets) + .context("Failed to serialize client")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported clients to clients/").green() + ); + Ok(()) + }); } - println!( - " {} {}", - SUCCESS, - style("Exported clients to clients/").green() - ); // Fetch roles - let roles = client.get_roles().await.context("Failed to fetch roles")?; - let roles_dir = workspace_dir.join("roles"); - if !fs::try_exists(&roles_dir) - .await - .context("Failed to check roles directory")? { - fs::create_dir_all(&roles_dir) - .await - .context("Failed to create roles directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for role in roles { - let roles_dir = roles_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = role.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = roles_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_role", realm_name); - let yaml = to_sorted_yaml_with_secrets(&role, &prefix, &mut local_secrets) - .context("Failed to serialize role")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let roles = client.get_roles().await.context("Failed to fetch roles")?; + let roles_dir = workspace_dir.join("roles"); + if !fs::try_exists(&roles_dir) + .await + .context("Failed to check roles directory")? + { + fs::create_dir_all(&roles_dir) + .await + .context("Failed to create roles directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for role in roles { + let roles_dir = roles_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = role.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = roles_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_role", realm_name); + let yaml = to_sorted_yaml_with_secrets(&role, &prefix, &mut local_secrets) + .context("Failed to serialize role")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported roles to roles/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported roles to roles/").green() - ); // Fetch client scopes - let client_scopes = client - .get_client_scopes() - .await - .context("Failed to fetch client scopes")?; - let scopes_dir = workspace_dir.join("client-scopes"); - if !fs::try_exists(&scopes_dir) - .await - .context("Failed to check client-scopes directory")? { - fs::create_dir_all(&scopes_dir) - .await - .context("Failed to create client-scopes directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for scope in client_scopes { - let scopes_dir = scopes_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = scope.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = scopes_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_client_scope", realm_name); - let yaml = to_sorted_yaml_with_secrets(&scope, &prefix, &mut local_secrets) - .context("Failed to serialize client scope")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let client_scopes = client + .get_client_scopes() + .await + .context("Failed to fetch client scopes")?; + let scopes_dir = workspace_dir.join("client-scopes"); + if !fs::try_exists(&scopes_dir) + .await + .context("Failed to check client-scopes directory")? + { + fs::create_dir_all(&scopes_dir) + .await + .context("Failed to create client-scopes directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for scope in client_scopes { + let scopes_dir = scopes_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = scope.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = scopes_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_client_scope", realm_name); + let yaml = to_sorted_yaml_with_secrets(&scope, &prefix, &mut local_secrets) + .context("Failed to serialize client scope")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported client scopes to client-scopes/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported client scopes to client-scopes/").green() - ); // Fetch identity providers - let idps = client - .get_identity_providers() - .await - .context("Failed to fetch identity providers")?; - let idps_dir = workspace_dir.join("identity-providers"); - if !fs::try_exists(&idps_dir) - .await - .context("Failed to check identity-providers directory")? { - fs::create_dir_all(&idps_dir) - .await - .context("Failed to create identity-providers directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for idp in idps { - let idps_dir = idps_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = idp.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = idps_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_idp", realm_name); - let yaml = to_sorted_yaml_with_secrets(&idp, &prefix, &mut local_secrets) - .context("Failed to serialize identity provider")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let idps = client + .get_identity_providers() + .await + .context("Failed to fetch identity providers")?; + let idps_dir = workspace_dir.join("identity-providers"); + if !fs::try_exists(&idps_dir) + .await + .context("Failed to check identity-providers directory")? + { + fs::create_dir_all(&idps_dir) + .await + .context("Failed to create identity-providers directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for idp in idps { + let idps_dir = idps_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = idp.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = idps_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_idp", realm_name); + let yaml = to_sorted_yaml_with_secrets(&idp, &prefix, &mut local_secrets) + .context("Failed to serialize identity provider")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported identity providers to identity-providers/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported identity providers to identity-providers/").green() - ); // Fetch groups - let groups = client - .get_groups() - .await - .context("Failed to fetch groups")?; - let groups_dir = workspace_dir.join("groups"); - if !fs::try_exists(&groups_dir) - .await - .context("Failed to check groups directory")? { - fs::create_dir_all(&groups_dir) - .await - .context("Failed to create groups directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for group in groups { - let groups_dir = groups_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = group.get_name(); - let id = group.id.as_deref().unwrap_or("unknown"); - let filename = format!("{}-{}.yaml", sanitize(&name), id); - let path = groups_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_group", realm_name); - let yaml = to_sorted_yaml_with_secrets(&group, &prefix, &mut local_secrets) - .context("Failed to serialize group")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let groups = client + .get_groups() + .await + .context("Failed to fetch groups")?; + let groups_dir = workspace_dir.join("groups"); + if !fs::try_exists(&groups_dir) + .await + .context("Failed to check groups directory")? + { + fs::create_dir_all(&groups_dir) + .await + .context("Failed to create groups directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for group in groups { + let groups_dir = groups_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = group.get_name(); + let id = group.id.as_deref().unwrap_or("unknown"); + let filename = format!("{}-{}.yaml", sanitize(&name), id); + let path = groups_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_group", realm_name); + let yaml = to_sorted_yaml_with_secrets(&group, &prefix, &mut local_secrets) + .context("Failed to serialize group")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported groups to groups/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported groups to groups/").green() - ); // Fetch users - let users = client.get_users().await.context("Failed to fetch users")?; - let users_dir = workspace_dir.join("users"); - if !fs::try_exists(&users_dir) - .await - .context("Failed to check users directory")? { - fs::create_dir_all(&users_dir) - .await - .context("Failed to create users directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for user in users { - let users_dir = users_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = user.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = users_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_user", realm_name); - let yaml = to_sorted_yaml_with_secrets(&user, &prefix, &mut local_secrets) - .context("Failed to serialize user")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let users = client.get_users().await.context("Failed to fetch users")?; + let users_dir = workspace_dir.join("users"); + if !fs::try_exists(&users_dir) + .await + .context("Failed to check users directory")? + { + fs::create_dir_all(&users_dir) + .await + .context("Failed to create users directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for user in users { + let users_dir = users_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = user.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = users_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_user", realm_name); + let yaml = to_sorted_yaml_with_secrets(&user, &prefix, &mut local_secrets) + .context("Failed to serialize user")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported users to users/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported users to users/").green() - ); // Fetch authentication flows - let flows = client - .get_authentication_flows() - .await - .context("Failed to fetch authentication flows")?; - let flows_dir = workspace_dir.join("authentication-flows"); - if !fs::try_exists(&flows_dir) - .await - .context("Failed to check authentication-flows directory")? { - fs::create_dir_all(&flows_dir) - .await - .context("Failed to create authentication-flows directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for flow in flows { - let flows_dir = flows_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = flow.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = flows_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_flow", realm_name); - let yaml = to_sorted_yaml_with_secrets(&flow, &prefix, &mut local_secrets) - .context("Failed to serialize authentication flow")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let flows = client + .get_authentication_flows() + .await + .context("Failed to fetch authentication flows")?; + let flows_dir = workspace_dir.join("authentication-flows"); + if !fs::try_exists(&flows_dir) + .await + .context("Failed to check authentication-flows directory")? + { + fs::create_dir_all(&flows_dir) + .await + .context("Failed to create authentication-flows directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for flow in flows { + let flows_dir = flows_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = flow.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = flows_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_flow", realm_name); + let yaml = to_sorted_yaml_with_secrets(&flow, &prefix, &mut local_secrets) + .context("Failed to serialize authentication flow")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported authentication flows to authentication-flows/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported authentication flows to authentication-flows/").green() - ); // Fetch required actions - let actions = client - .get_required_actions() - .await - .context("Failed to fetch required actions")?; - let actions_dir = workspace_dir.join("required-actions"); - if !fs::try_exists(&actions_dir) - .await - .context("Failed to check required-actions directory")? { - fs::create_dir_all(&actions_dir) - .await - .context("Failed to create required-actions directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for action in actions { - let actions_dir = actions_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); + let client = client.clone(); let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = action.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = actions_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_action", realm_name); - let yaml = to_sorted_yaml_with_secrets(&action, &prefix, &mut local_secrets) - .context("Failed to serialize required action")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + master_set.spawn(async move { + let actions = client + .get_required_actions() + .await + .context("Failed to fetch required actions")?; + let actions_dir = workspace_dir.join("required-actions"); + if !fs::try_exists(&actions_dir) + .await + .context("Failed to check required-actions directory")? + { + fs::create_dir_all(&actions_dir) + .await + .context("Failed to create required-actions directory")?; + } + let mut set = tokio::task::JoinSet::new(); + for action in actions { + let actions_dir = actions_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = action.get_name(); + let filename = format!("{}.yaml", sanitize(&name)); + let path = actions_dir.join(filename); + let mut local_secrets = HashMap::new(); + let prefix = format!("realm_{}_action", realm_name); + let yaml = to_sorted_yaml_with_secrets(&action, &prefix, &mut local_secrets) + .context("Failed to serialize required action")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported required actions to required-actions/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported required actions to required-actions/").green() - ); // Fetch components and keys - let all_components = client - .get_components() - .await - .context("Failed to fetch components")?; - - let components_dir = workspace_dir.join("components"); - if !fs::try_exists(&components_dir) - .await - .context("Failed to check components directory")? { - fs::create_dir_all(&components_dir) - .await - .context("Failed to create components directory")?; - } + let client = client.clone(); + let realm_name = realm_name.to_string(); + let workspace_dir = workspace_dir.clone(); + let all_secrets = Arc::clone(&all_secrets); + let prompt_mutex = Arc::clone(&prompt_mutex); + master_set.spawn(async move { + let all_components = client + .get_components() + .await + .context("Failed to fetch components")?; - let keys_dir = workspace_dir.join("keys"); - if !fs::try_exists(&keys_dir) - .await - .context("Failed to check keys directory")? - { - fs::create_dir_all(&keys_dir) - .await - .context("Failed to create keys directory")?; - } + let components_dir = workspace_dir.join("components"); + if !fs::try_exists(&components_dir) + .await + .context("Failed to check components directory")? + { + fs::create_dir_all(&components_dir) + .await + .context("Failed to create components directory")?; + } + + let keys_dir = workspace_dir.join("keys"); + if !fs::try_exists(&keys_dir) + .await + .context("Failed to check keys directory")? + { + fs::create_dir_all(&keys_dir) + .await + .context("Failed to create keys directory")?; + } - let mut set = tokio::task::JoinSet::new(); - for component in all_components { - let is_key = component - .provider_type - .as_deref() - .is_some_and(|pt| pt == "org.keycloak.keys.KeyProvider"); - let target_dir = if is_key { - keys_dir.clone() - } else { - components_dir.clone() - }; + let mut set = tokio::task::JoinSet::new(); + for component in all_components { + let is_key = component + .provider_type + .as_deref() + .is_some_and(|pt| pt == "org.keycloak.keys.KeyProvider"); + let target_dir = if is_key { + keys_dir.clone() + } else { + components_dir.clone() + }; - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = component.get_name(); - let id = component.id.as_deref().unwrap_or("unknown"); - let filename = format!("{}-{}.yaml", sanitize(&name), id); - let path = target_dir.join(filename); - let mut local_secrets = HashMap::new(); - let sub_prefix = if is_key { "key" } else { "component" }; - let prefix = format!("realm_{}_{}", realm_name, sub_prefix); - let yaml = to_sorted_yaml_with_secrets(&component, &prefix, &mut local_secrets) - .context("Failed to serialize component")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + let all_secrets = Arc::clone(&all_secrets); + let realm_name = realm_name.clone(); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + let name = component.get_name(); + let id = component.id.as_deref().unwrap_or("unknown"); + let filename = format!("{}-{}.yaml", sanitize(&name), id); + let path = target_dir.join(filename); + let mut local_secrets = HashMap::new(); + let sub_prefix = if is_key { "key" } else { "component" }; + let prefix = format!("realm_{}_{}", realm_name, sub_prefix); + let yaml = to_sorted_yaml_with_secrets(&component, &prefix, &mut local_secrets) + .context("Failed to serialize component")?; + all_secrets.lock().await.extend(local_secrets); + write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await + }); + } + while let Some(res) = set.join_next().await { + res.context("Task panicked")??; + } + println!( + " {} {}", + SUCCESS, + style("Exported components to components/ and keys to keys/").green() + ); + Ok(()) }); } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; + + while let Some(res) = master_set.join_next().await { + res.context("Master task panicked")??; } - println!( - " {} {}", - SUCCESS, - style("Exported components to components/ and keys to keys/").green() - ); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 9e51f2b..b6ce711 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,128 @@ pub mod models; pub mod plan; pub mod utils; pub mod validate; + +use anyhow::{Context, Result}; +use args::{Cli, Commands}; +use client::KeycloakClient; +use console::{Emoji, style}; + +static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); +static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", "> "); + +pub async fn init_client(cli: &Cli) -> Result { + let mut client = KeycloakClient::new(cli.server.clone()); + client + .login( + &cli.client_id, + cli.client_secret.as_deref(), + cli.user.as_deref(), + cli.password.as_deref(), + ) + .await + .context("Login failed")?; + Ok(client) +} + +pub async fn run_app(cli: Cli) -> Result<()> { + match &cli.command { + Commands::Inspect { workspace, yes } => { + let client = init_client(&cli).await?; + println!( + "{} {}", + SEARCH, + style(format!( + "Inspecting Keycloak configuration into {:?}", + workspace + )) + .cyan() + .bold() + ); + inspect::run(&client, workspace.clone(), &cli.realms, *yes).await?; + } + Commands::Validate { workspace } => { + println!( + "{} {}", + SEARCH, + style(format!( + "Validating Keycloak configuration from {:?}", + workspace + )) + .cyan() + .bold() + ); + validate::run(workspace.clone(), &cli.realms).await?; + } + Commands::Apply { workspace, yes } => { + let client = init_client(&cli).await?; + println!( + "{} {}", + ACTION, + style(format!( + "Applying Keycloak configuration from {:?}", + workspace + )) + .cyan() + .bold() + ); + apply::run(&client, workspace.clone(), &cli.realms, *yes).await?; + } + Commands::Plan { + workspace, + changes_only, + interactive, + } => { + let client = init_client(&cli).await?; + println!( + "{} {}", + SEARCH, + style(format!( + "Planning Keycloak configuration from {:?}", + workspace + )) + .cyan() + .bold() + ); + plan::run( + &client, + workspace.clone(), + *changes_only, + *interactive, + &cli.realms, + ) + .await?; + } + Commands::Drift { workspace } => { + let client = init_client(&cli).await?; + println!( + "{} {}", + SEARCH, + style(format!( + "Checking drift for Keycloak configuration from {:?}", + workspace + )) + .cyan() + .bold() + ); + plan::run(&client, workspace.clone(), true, false, &cli.realms).await?; + } + Commands::Cli { workspace } => { + cli::run(workspace.clone()).await?; + } + Commands::Clean { workspace, yes } => { + println!( + "{} {}", + ACTION, + style(format!( + "Cleaning up Keycloak configuration in {:?}", + workspace + )) + .cyan() + .bold() + ); + clean::run(workspace.clone(), *yes, &cli.realms).await?; + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9e3207e..de6923c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,31 +1,6 @@ -use anyhow::{Context, Result}; -use app::apply; -use app::args::{Cli, Commands}; -use app::clean; -use app::cli as interactive_cli; -use app::client::KeycloakClient; -use app::inspect; -use app::plan; -use app::validate; +use anyhow::Result; +use app::args::Cli; use clap::Parser; -use console::{Emoji, style}; - -static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); -static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", "> "); - -async fn init_client(cli: &Cli) -> Result { - let mut client = KeycloakClient::new(cli.server.clone()); - client - .login( - &cli.client_id, - cli.client_secret.as_deref(), - cli.user.as_deref(), - cli.password.as_deref(), - ) - .await - .context("Login failed")?; - Ok(client) -} #[tokio::main] async fn main() -> Result<()> { @@ -42,104 +17,5 @@ async fn main() -> Result<()> { cli.client_secret = std::env::var("KEYCLOAK_CLIENT_SECRET").ok(); } - match &cli.command { - Commands::Inspect { workspace, yes } => { - let client = init_client(&cli).await?; - println!( - "{} {}", - SEARCH, - style(format!( - "Inspecting Keycloak configuration into {:?}", - workspace - )) - .cyan() - .bold() - ); - inspect::run(&client, workspace.clone(), &cli.realms, *yes).await?; - } - Commands::Validate { workspace } => { - println!( - "{} {}", - SEARCH, - style(format!( - "Validating Keycloak configuration from {:?}", - workspace - )) - .cyan() - .bold() - ); - validate::run(workspace.clone(), &cli.realms)?; - } - Commands::Apply { workspace, yes } => { - let client = init_client(&cli).await?; - println!( - "{} {}", - ACTION, - style(format!( - "Applying Keycloak configuration from {:?}", - workspace - )) - .cyan() - .bold() - ); - apply::run(&client, workspace.clone(), &cli.realms, *yes).await?; - } - Commands::Plan { - workspace, - changes_only, - interactive, - } => { - let client = init_client(&cli).await?; - println!( - "{} {}", - SEARCH, - style(format!( - "Planning Keycloak configuration from {:?}", - workspace - )) - .cyan() - .bold() - ); - plan::run( - &client, - workspace.clone(), - *changes_only, - *interactive, - &cli.realms, - ) - .await?; - } - Commands::Drift { workspace } => { - let client = init_client(&cli).await?; - println!( - "{} {}", - SEARCH, - style(format!( - "Checking drift for Keycloak configuration from {:?}", - workspace - )) - .cyan() - .bold() - ); - plan::run(&client, workspace.clone(), true, false, &cli.realms).await?; - } - Commands::Cli { workspace } => { - interactive_cli::run(workspace.clone()).await?; - } - Commands::Clean { workspace, yes } => { - println!( - "{} {}", - ACTION, - style(format!( - "Cleaning up Keycloak configuration in {:?}", - workspace - )) - .cyan() - .bold() - ); - clean::run(workspace.clone(), *yes, &cli.realms).await?; - } - } - - Ok(()) + app::run_app(cli).await } diff --git a/src/models.rs b/src/models.rs index 2ff2e7f..7a8015e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -118,7 +118,7 @@ pub struct ClientRepresentation { impl KeycloakResource for ClientRepresentation { fn get_identity(&self) -> Option { - self.id.clone().or_else(|| self.client_id.clone()) + self.client_id.clone().or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.client_id @@ -147,7 +147,7 @@ pub struct RoleRepresentation { impl KeycloakResource for RoleRepresentation { fn get_identity(&self) -> Option { - self.id.clone().or_else(|| Some(self.name.clone())) + Some(self.name.clone()).or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.name.clone() @@ -172,7 +172,7 @@ pub struct ClientScopeRepresentation { impl KeycloakResource for ClientScopeRepresentation { fn get_identity(&self) -> Option { - self.id.clone().or_else(|| self.name.clone()) + self.name.clone().or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) @@ -195,7 +195,7 @@ pub struct GroupRepresentation { impl KeycloakResource for GroupRepresentation { fn get_identity(&self) -> Option { - self.id.clone().or_else(|| self.name.clone()) + self.name.clone().or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) @@ -240,7 +240,7 @@ pub struct UserRepresentation { impl KeycloakResource for UserRepresentation { fn get_identity(&self) -> Option { - self.id.clone().or_else(|| self.username.clone()) + self.username.clone().or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.username @@ -297,7 +297,7 @@ pub struct AuthenticationFlowRepresentation { impl KeycloakResource for AuthenticationFlowRepresentation { fn get_identity(&self) -> Option { - self.id.clone().or_else(|| self.alias.clone()) + self.alias.clone().or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.alias.clone().unwrap_or_else(|| "unknown".to_string()) @@ -355,7 +355,7 @@ pub struct ComponentRepresentation { impl KeycloakResource for ComponentRepresentation { fn get_identity(&self) -> Option { - self.id.clone() + self.name.clone().or_else(|| self.id.clone()) } fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) diff --git a/src/plan.rs b/src/plan.rs deleted file mode 100644 index 21a3f10..0000000 --- a/src/plan.rs +++ /dev/null @@ -1,1119 +0,0 @@ -use crate::client::KeycloakClient; -use crate::models::{ - AuthenticationFlowRepresentation, ClientRepresentation, ClientScopeRepresentation, - ComponentRepresentation, GroupRepresentation, IdentityProviderRepresentation, KeycloakResource, - RealmRepresentation, RequiredActionProviderRepresentation, RoleRepresentation, - UserRepresentation, -}; - -use anyhow::{Context, Result}; -use console::{Emoji, Style, style}; -use serde::Serialize; -use similar::{ChangeTag, TextDiff}; -use std::collections::HashMap; -use std::env; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::fs as async_fs; - -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); -static ACTION: Emoji<'_, '_> = Emoji("🔍 ", "> "); - -pub async fn run( - client: &KeycloakClient, - workspace_dir: PathBuf, - changes_only: bool, - interactive: bool, - realms_to_plan: &[String], -) -> Result<()> { - if !workspace_dir.exists() { - anyhow::bail!("Input directory {:?} does not exist", workspace_dir); - } - - // Load .secrets from input directory if it exists - let env_path = workspace_dir.join(".secrets"); - if env_path.exists() { - dotenvy::from_path(&env_path).ok(); - } - - let env_vars = Arc::new(env::vars().collect::>()); - - let realms = if realms_to_plan.is_empty() { - let mut dirs = Vec::new(); - let mut entries = async_fs::read_dir(&workspace_dir).await?; - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await?.is_dir() { - dirs.push(entry.file_name().to_string_lossy().to_string()); - } - } - dirs - } else { - realms_to_plan.to_vec() - }; - - if realms.is_empty() { - println!( - "{} {}", - WARN, - style(format!("No realms found to plan in {:?}", workspace_dir)).yellow() - ); - return Ok(()); - } - - let mut changed_files = Vec::new(); - for realm_name in realms { - let mut realm_client = client.clone(); - realm_client.set_target_realm(realm_name.clone()); - let realm_dir = workspace_dir.join(&realm_name); - println!( - "\n{} {}", - ACTION, - style(format!("Planning changes for realm: {}", realm_name)) - .cyan() - .bold() - ); - plan_single_realm( - &realm_client, - realm_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - &mut changed_files, - ) - .await?; - } - - let plan_file = workspace_dir.join(".kcdplan"); - if changed_files.is_empty() { - if async_fs::try_exists(&plan_file).await? { - async_fs::remove_file(&plan_file).await?; - } - } else { - let content = serde_json::to_string_pretty(&changed_files)?; - async_fs::write(&plan_file, content).await?; - } - - Ok(()) -} - -async fn plan_single_realm( - client: &KeycloakClient, - workspace_dir: PathBuf, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - // 1. Plan Realm - plan_realm( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 2. Plan Roles - plan_roles( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 3. Plan Clients - plan_clients( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 4. Plan Identity Providers - plan_identity_providers( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 5. Plan Client Scopes - plan_client_scopes( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 6. Plan Groups - plan_groups( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 7. Plan Users - plan_users( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 8. Plan Authentication Flows - plan_authentication_flows( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 9. Plan Required Actions - plan_required_actions( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - ) - .await?; - - // 10. Plan Components - plan_components_or_keys( - client, - &workspace_dir, - changes_only, - interactive, - "components", - Arc::clone(&env_vars), - changed_files, - ) - .await?; - plan_components_or_keys( - client, - &workspace_dir, - changes_only, - interactive, - "keys", - Arc::clone(&env_vars), - changed_files, - ) - .await?; - check_keys_drift(client, changes_only).await?; - - Ok(()) -} - -use crate::utils::secrets::{obfuscate_secrets, substitute_secrets}; - -fn print_diff( - name: &str, - old: Option<&T>, - new: &T, - changes_only: bool, - prefix: &str, -) -> Result { - let old_yaml = if let Some(o) = old { - let mut val = serde_json::to_value(o)?; - obfuscate_secrets(&mut val, prefix); - crate::utils::to_sorted_yaml(&val)? - } else { - String::new() - }; - - let mut new_val = serde_json::to_value(new)?; - obfuscate_secrets(&mut new_val, prefix); - let new_yaml = crate::utils::to_sorted_yaml(&new_val)?; - - let diff = TextDiff::from_lines(&old_yaml, &new_yaml); - let changed = diff.ratio() < 1.0; - - if changed { - println!("\n{} Changes for {}:", Emoji("📝", ""), name); - for change in diff.iter_all_changes() { - let (sign, style) = match change.tag() { - ChangeTag::Delete => ("-", Style::new().red()), - ChangeTag::Insert => ("+", Style::new().green()), - ChangeTag::Equal => (" ", Style::new().dim()), - }; - print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change)); - } - } else if !changes_only { - println!("{} No changes for {}", Emoji("✅", ""), name); - } - Ok(changed) -} - -async fn plan_client_scopes( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let scopes_dir = workspace_dir.join("client-scopes"); - if async_fs::try_exists(&scopes_dir).await? { - let existing_scopes = client.get_client_scopes().await?; - let existing_scopes_map: HashMap = existing_scopes - .into_iter() - .filter_map(|s| s.get_identity().map(|id| (id, s))) - .collect(); - - let mut entries = async_fs::read_dir(&scopes_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_scope: ClientScopeRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_scope - .get_identity() - .context(format!("Failed to get identity for scope in {:?}", path))?; - let remote = existing_scopes_map.get(&identity); - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_scope.id.is_none() { - remote_clone.id = None; - } - print_diff( - &format!("ClientScope {}", local_scope.get_name()), - Some(&remote_clone), - &local_scope, - changes_only, - "client_scope", - )? - } else { - println!( - "\n{} Will create ClientScope: {}", - Emoji("✨", ""), - local_scope.get_name() - ); - print_diff( - &format!("ClientScope {}", local_scope.get_name()), - None::<&ClientScopeRepresentation>, - &local_scope, - changes_only, - "client_scope", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_groups( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let groups_dir = workspace_dir.join("groups"); - if async_fs::try_exists(&groups_dir).await? { - let existing_groups = client.get_groups().await?; - let existing_groups_map: HashMap = existing_groups - .into_iter() - .filter_map(|g| g.get_identity().map(|id| (id, g))) - .collect(); - - let mut entries = async_fs::read_dir(&groups_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_group: GroupRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_group - .get_identity() - .context(format!("Failed to get identity for group in {:?}", path))?; - let remote = existing_groups_map.get(&identity); - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_group.id.is_none() { - remote_clone.id = None; - } - print_diff( - &format!("Group {}", local_group.get_name()), - Some(&remote_clone), - &local_group, - changes_only, - "group", - )? - } else { - println!( - "\n{} Will create Group: {}", - Emoji("✨", ""), - local_group.get_name() - ); - print_diff( - &format!("Group {}", local_group.get_name()), - None::<&GroupRepresentation>, - &local_group, - changes_only, - "group", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_users( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let users_dir = workspace_dir.join("users"); - if async_fs::try_exists(&users_dir).await? { - let existing_users = client.get_users().await?; - let existing_users_map: HashMap = existing_users - .into_iter() - .filter_map(|u| u.get_identity().map(|id| (id, u))) - .collect(); - - let mut entries = async_fs::read_dir(&users_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_user: UserRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_user - .get_identity() - .context(format!("Failed to get identity for user in {:?}", path))?; - let remote = existing_users_map.get(&identity); - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_user.id.is_none() { - remote_clone.id = None; - } - print_diff( - &format!("User {}", local_user.get_name()), - Some(&remote_clone), - &local_user, - changes_only, - "user", - )? - } else { - println!( - "\n{} Will create User: {}", - Emoji("✨", ""), - local_user.get_name() - ); - print_diff( - &format!("User {}", local_user.get_name()), - None::<&UserRepresentation>, - &local_user, - changes_only, - "user", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_authentication_flows( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let flows_dir = workspace_dir.join("authentication-flows"); - if async_fs::try_exists(&flows_dir).await? { - let existing_flows = client.get_authentication_flows().await?; - let existing_flows_map: HashMap = existing_flows - .into_iter() - .filter_map(|f| f.get_identity().map(|id| (id, f))) - .collect(); - - let mut entries = async_fs::read_dir(&flows_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_flow: AuthenticationFlowRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_flow - .get_identity() - .context(format!("Failed to get identity for flow in {:?}", path))?; - let remote = existing_flows_map.get(&identity); - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_flow.id.is_none() { - remote_clone.id = None; - } - print_diff( - &format!("AuthenticationFlow {}", local_flow.get_name()), - Some(&remote_clone), - &local_flow, - changes_only, - "flow", - )? - } else { - println!( - "\n{} Will create AuthenticationFlow: {}", - Emoji("✨", ""), - local_flow.get_name() - ); - print_diff( - &format!("AuthenticationFlow {}", local_flow.get_name()), - None::<&AuthenticationFlowRepresentation>, - &local_flow, - changes_only, - "flow", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_required_actions( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let actions_dir = workspace_dir.join("required-actions"); - if async_fs::try_exists(&actions_dir).await? { - let existing_actions = client.get_required_actions().await?; - let existing_actions_map: HashMap = - existing_actions - .into_iter() - .filter_map(|a| a.get_identity().map(|id| (id, a))) - .collect(); - - let mut entries = async_fs::read_dir(&actions_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_action: RequiredActionProviderRepresentation = - serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_action - .get_identity() - .context(format!("Failed to get identity for action in {:?}", path))?; - let remote = existing_actions_map.get(&identity); - - let changed = if let Some(remote) = remote { - let remote_clone = remote.clone(); - print_diff( - &format!("RequiredAction {}", local_action.get_name()), - Some(&remote_clone), - &local_action, - changes_only, - "action", - )? - } else { - println!( - "\n{} Will create RequiredAction: {}", - Emoji("✨", ""), - local_action.get_name() - ); - print_diff( - &format!("RequiredAction {}", local_action.get_name()), - None::<&RequiredActionProviderRepresentation>, - &local_action, - changes_only, - "action", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_components_or_keys( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - dir_name: &str, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let components_dir = workspace_dir.join(dir_name); - if async_fs::try_exists(&components_dir).await? { - let existing_components = client.get_components().await?; - let mut by_identity: HashMap = HashMap::new(); - type ComponentKey = ( - Option, - Option, - Option, - Option, - ); - let mut by_details: HashMap = HashMap::new(); - - for c in existing_components { - if let Some(id) = c.get_identity() { - by_identity.insert(id, c.clone()); - } - let key = ( - c.name.clone(), - c.sub_type.clone(), - c.provider_id.clone(), - c.parent_id.clone(), - ); - by_details.insert(key, c); - } - - let mut entries = async_fs::read_dir(&components_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_component: ComponentRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let remote = if let Some(identity) = local_component.get_identity() { - by_identity.get(&identity).or_else(|| { - let key = ( - local_component.name.clone(), - local_component.sub_type.clone(), - local_component.provider_id.clone(), - local_component.parent_id.clone(), - ); - by_details.get(&key) - }) - } else { - let key = ( - local_component.name.clone(), - local_component.sub_type.clone(), - local_component.provider_id.clone(), - local_component.parent_id.clone(), - ); - by_details.get(&key) - }; - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_component.id.is_none() { - remote_clone.id = None; - } - let prefix = if dir_name == "keys" { - "key" - } else { - "component" - }; - print_diff( - &format!("Component {}", local_component.get_name()), - Some(&remote_clone), - &local_component, - changes_only, - prefix, - )? - } else { - println!( - "\n{} Will create Component: {}", - Emoji("✨", ""), - local_component.get_name() - ); - let prefix = if dir_name == "keys" { - "key" - } else { - "component" - }; - print_diff( - &format!("Component {}", local_component.get_name()), - None::<&ComponentRepresentation>, - &local_component, - changes_only, - prefix, - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_realm( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let realm_path = workspace_dir.join("realm.yaml"); - if async_fs::try_exists(&realm_path).await? { - let content = async_fs::read_to_string(&realm_path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", realm_path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_realm: RealmRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", realm_path))?; - - // We handle the case where remote realm fetch might fail (e.g. if we are creating it) - // by treating it as None (creation). However, usually plan is run against existing realm. - let remote_realm = match client.get_realm().await { - Ok(r) => Some(r), - Err(e) => { - // Check if it's a 404 (Not Found) - if e.to_string().contains("404") { - None - } else { - return Err(e); - } - } - }; - - if print_diff( - "Realm", - remote_realm.as_ref(), - &local_realm, - changes_only, - "realm", - )? { - let mut include = true; - if interactive { - include = - dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(realm_path); - } - } - } - Ok(()) -} - -async fn plan_roles( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let roles_dir = workspace_dir.join("roles"); - if async_fs::try_exists(&roles_dir).await? { - let existing_roles = client.get_roles().await?; - let existing_roles_map: HashMap = existing_roles - .into_iter() - .filter_map(|r| r.get_identity().map(|id| (id, r))) - .collect(); - - let mut entries = async_fs::read_dir(&roles_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_role: RoleRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_role - .get_identity() - .context(format!("Failed to get identity for role in {:?}", path))?; - let remote_role = existing_roles_map.get(&identity); - - let changed = if let Some(remote) = remote_role { - let mut remote_clone = remote.clone(); - // Ignore ID differences if local doesn't specify it - if local_role.id.is_none() { - remote_clone.id = None; - remote_clone.container_id = None; - } - print_diff( - &format!("Role {}", local_role.get_name()), - Some(&remote_clone), - &local_role, - changes_only, - "role", - )? - } else { - println!( - "\n{} Will create Role: {}", - Emoji("✨", ""), - local_role.get_name() - ); - print_diff( - &format!("Role {}", local_role.get_name()), - None::<&RoleRepresentation>, - &local_role, - changes_only, - "role", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_clients( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let clients_dir = workspace_dir.join("clients"); - if async_fs::try_exists(&clients_dir).await? { - let existing_clients = client.get_clients().await?; - let existing_clients_map: HashMap = existing_clients - .into_iter() - .filter_map(|c| c.get_identity().map(|id| (id, c))) - .collect(); - - let mut entries = async_fs::read_dir(&clients_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_client: ClientRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_client - .get_identity() - .context(format!("Failed to get identity for client in {:?}", path))?; - let remote = existing_clients_map.get(&identity); - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_client.id.is_none() { - remote_clone.id = None; - } - print_diff( - &format!("Client {}", local_client.get_name()), - Some(&remote_clone), - &local_client, - changes_only, - "client", - )? - } else { - println!( - "\n{} Will create Client: {}", - Emoji("✨", ""), - local_client.get_name() - ); - print_diff( - &format!("Client {}", local_client.get_name()), - None::<&ClientRepresentation>, - &local_client, - changes_only, - "client", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -async fn plan_identity_providers( - client: &KeycloakClient, - workspace_dir: &Path, - changes_only: bool, - interactive: bool, - env_vars: Arc>, - changed_files: &mut Vec, -) -> Result<()> { - let idps_dir = workspace_dir.join("identity-providers"); - if async_fs::try_exists(&idps_dir).await? { - let existing_idps = client.get_identity_providers().await?; - let existing_idps_map: HashMap = existing_idps - .into_iter() - .filter_map(|i| i.get_identity().map(|id| (id, i))) - .collect(); - - let mut entries = async_fs::read_dir(&idps_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; - substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; - let local_idp: IdentityProviderRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; - - let identity = local_idp - .get_identity() - .context(format!("Failed to get identity for IDP in {:?}", path))?; - let remote = existing_idps_map.get(&identity); - - let changed = if let Some(remote) = remote { - let mut remote_clone = remote.clone(); - if local_idp.internal_id.is_none() { - remote_clone.internal_id = None; - } - print_diff( - &format!("IdentityProvider {}", local_idp.get_name()), - Some(&remote_clone), - &local_idp, - changes_only, - "idp", - )? - } else { - println!( - "\n{} Will create IdentityProvider: {}", - Emoji("✨", ""), - local_idp.get_name() - ); - print_diff( - &format!("IdentityProvider {}", local_idp.get_name()), - None::<&IdentityProviderRepresentation>, - &local_idp, - changes_only, - "idp", - )? - }; - - if changed { - let mut include = true; - if interactive { - include = dialoguer::Confirm::with_theme( - &dialoguer::theme::ColorfulTheme::default(), - ) - .with_prompt("Include this change in the plan?") - .default(true) - .interact()?; - } - if include { - changed_files.push(path); - } - } - } - } - } - Ok(()) -} - -use std::time::{SystemTime, UNIX_EPOCH}; - -async fn check_keys_drift(client: &KeycloakClient, changes_only: bool) -> Result<()> { - if !changes_only { - return Ok(()); - } - - let keys_metadata = match client.get_keys().await { - Ok(km) => km, - Err(_) => return Ok(()), // Ignore if not available - }; - - if let Some(keys) = keys_metadata.keys { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - let thirty_days = 30 * 24 * 60 * 60 * 1000; // 30 days in ms - - for key in keys { - #[allow(clippy::collapsible_if)] - if key.status.as_deref() == Some("ACTIVE") { - if let Some(valid_to) = key.valid_to { - #[allow(clippy::collapsible_if)] - if valid_to > 0 && valid_to - now < thirty_days { - let provider_id = key.provider_id.as_deref().unwrap_or("unknown"); - println!( - "{} Warning: Active key (providerId: {}) is near expiration or expired! Consider rotating keys.", - Emoji("⚠️", ""), - style(provider_id).yellow() - ); - } - } - } - } - } - - Ok(()) -} diff --git a/src/plan/actions.rs b/src/plan/actions.rs new file mode 100644 index 0000000..e21ff1e --- /dev/null +++ b/src/plan/actions.rs @@ -0,0 +1,110 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, RequiredActionProviderRepresentation}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_required_actions( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let actions_dir = workspace_dir.join("required-actions"); + if async_fs::try_exists(&actions_dir).await? { + let existing_actions = client.get_required_actions().await?; + let existing_actions_map: HashMap = + existing_actions + .into_iter() + .filter_map(|a| a.get_identity().map(|id| (id, a))) + .collect(); + let existing_actions_map = Arc::new(existing_actions_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&actions_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_actions_map = existing_actions_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_action: RequiredActionProviderRepresentation = + serde_json::from_value(val).with_context(|| { + format!("Failed to deserialize YAML file: {:?}", path) + })?; + + let identity = local_action + .get_identity() + .context(format!("Failed to get identity for action in {:?}", path))?; + let remote = existing_actions_map.get(&identity).cloned(); + + Ok::< + ( + RequiredActionProviderRepresentation, + PathBuf, + Option, + ), + anyhow::Error, + >((local_action, path, remote)) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_action, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let remote_clone = remote.clone(); + print_diff( + &format!("RequiredAction {}", local_action.get_name()), + Some(&remote_clone), + &local_action, + changes_only, + "action", + )? + } else { + println!( + "\n{} Will create RequiredAction: {}", + Emoji("✨", ""), + local_action.get_name() + ); + print_diff( + &format!("RequiredAction {}", local_action.get_name()), + None::<&RequiredActionProviderRepresentation>, + &local_action, + changes_only, + "action", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/clients.rs b/src/plan/clients.rs new file mode 100644 index 0000000..fb3207a --- /dev/null +++ b/src/plan/clients.rs @@ -0,0 +1,107 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_clients( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let clients_dir = workspace_dir.join("clients"); + if async_fs::try_exists(&clients_dir).await? { + let existing_clients = client.get_clients().await?; + let existing_clients_map: HashMap = existing_clients + .into_iter() + .filter_map(|c| c.get_identity().map(|id| (id, c))) + .collect(); + let existing_clients_map = Arc::new(existing_clients_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&clients_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_clients_map = existing_clients_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_client: ClientRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_client + .get_identity() + .context(format!("Failed to get identity for client in {:?}", path))?; + let remote = existing_clients_map.get(&identity).cloned(); + + Ok::<(ClientRepresentation, PathBuf, Option), anyhow::Error>(( + local_client, + path, + remote, + )) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_client, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_client.id.is_none() { + remote_clone.id = None; + } + print_diff( + &format!("Client {}", local_client.get_name()), + Some(&remote_clone), + &local_client, + changes_only, + "client", + )? + } else { + println!( + "\n{} Will create Client: {}", + Emoji("✨", ""), + local_client.get_name() + ); + print_diff( + &format!("Client {}", local_client.get_name()), + None::<&ClientRepresentation>, + &local_client, + changes_only, + "client", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/components.rs b/src/plan/components.rs new file mode 100644 index 0000000..f179ff8 --- /dev/null +++ b/src/plan/components.rs @@ -0,0 +1,198 @@ +use crate::client::KeycloakClient; +use crate::models::{ComponentRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::{Emoji, style}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_components_or_keys( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + dir_name: &str, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let components_dir = workspace_dir.join(dir_name); + if async_fs::try_exists(&components_dir).await? { + let existing_components = client.get_components().await?; + let mut by_identity: HashMap = HashMap::new(); + type ComponentKey = ( + Option, + Option, + Option, + Option, + ); + let mut by_details: HashMap = HashMap::new(); + + for c in existing_components { + if let Some(id) = c.get_identity() { + by_identity.insert(id, c.clone()); + } + let key = ( + c.name.clone(), + c.sub_type.clone(), + c.provider_id.clone(), + c.parent_id.clone(), + ); + by_details.insert(key, c); + } + + let by_identity = Arc::new(by_identity); + let by_details = Arc::new(by_details); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&components_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let by_identity = by_identity.clone(); + let by_details = by_details.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_component: ComponentRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let remote = if let Some(identity) = local_component.get_identity() { + by_identity + .get(&identity) + .or_else(|| { + let key = ( + local_component.name.clone(), + local_component.sub_type.clone(), + local_component.provider_id.clone(), + local_component.parent_id.clone(), + ); + by_details.get(&key) + }) + .cloned() + } else { + let key = ( + local_component.name.clone(), + local_component.sub_type.clone(), + local_component.provider_id.clone(), + local_component.parent_id.clone(), + ); + by_details.get(&key).cloned() + }; + + Ok::< + ( + ComponentRepresentation, + PathBuf, + Option, + ), + anyhow::Error, + >((local_component, path, remote)) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_component, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_component.id.is_none() { + remote_clone.id = None; + } + let prefix = if dir_name == "keys" { + "key" + } else { + "component" + }; + print_diff( + &format!("Component {}", local_component.get_name()), + Some(&remote_clone), + &local_component, + changes_only, + prefix, + )? + } else { + println!( + "\n{} Will create Component: {}", + Emoji("✨", ""), + local_component.get_name() + ); + let prefix = if dir_name == "keys" { + "key" + } else { + "component" + }; + print_diff( + &format!("Component {}", local_component.get_name()), + None::<&ComponentRepresentation>, + &local_component, + changes_only, + prefix, + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} + +pub async fn check_keys_drift(client: &KeycloakClient, changes_only: bool) -> Result<()> { + if !changes_only { + return Ok(()); + } + + let keys_metadata = match client.get_keys().await { + Ok(km) => km, + Err(_) => return Ok(()), // Ignore if not available + }; + + if let Some(keys) = keys_metadata.keys { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let thirty_days = 30 * 24 * 60 * 60 * 1000; // 30 days in ms + + for key in keys { + #[allow(clippy::collapsible_if)] + if key.status.as_deref() == Some("ACTIVE") { + if let Some(valid_to) = key.valid_to { + #[allow(clippy::collapsible_if)] + if valid_to > 0 && valid_to - now < thirty_days { + let provider_id = key.provider_id.as_deref().unwrap_or("unknown"); + println!( + "{} Warning: Active key (providerId: {}) is near expiration or expired! Consider rotating keys.", + Emoji("⚠️", ""), + style(provider_id).yellow() + ); + } + } + } + } + } + + Ok(()) +} diff --git a/src/plan/flows.rs b/src/plan/flows.rs new file mode 100644 index 0000000..fb262b6 --- /dev/null +++ b/src/plan/flows.rs @@ -0,0 +1,110 @@ +use crate::client::KeycloakClient; +use crate::models::{AuthenticationFlowRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_authentication_flows( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let flows_dir = workspace_dir.join("authentication-flows"); + if async_fs::try_exists(&flows_dir).await? { + let existing_flows = client.get_authentication_flows().await?; + let existing_flows_map: HashMap = existing_flows + .into_iter() + .filter_map(|f| f.get_identity().map(|id| (id, f))) + .collect(); + let existing_flows_map = Arc::new(existing_flows_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&flows_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_flows_map = existing_flows_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_flow: AuthenticationFlowRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_flow + .get_identity() + .context(format!("Failed to get identity for flow in {:?}", path))?; + let remote = existing_flows_map.get(&identity).cloned(); + + Ok::< + ( + AuthenticationFlowRepresentation, + PathBuf, + Option, + ), + anyhow::Error, + >((local_flow, path, remote)) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_flow, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_flow.id.is_none() { + remote_clone.id = None; + } + print_diff( + &format!("AuthenticationFlow {}", local_flow.get_name()), + Some(&remote_clone), + &local_flow, + changes_only, + "flow", + )? + } else { + println!( + "\n{} Will create AuthenticationFlow: {}", + Emoji("✨", ""), + local_flow.get_name() + ); + print_diff( + &format!("AuthenticationFlow {}", local_flow.get_name()), + None::<&AuthenticationFlowRepresentation>, + &local_flow, + changes_only, + "flow", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/groups.rs b/src/plan/groups.rs new file mode 100644 index 0000000..547932e --- /dev/null +++ b/src/plan/groups.rs @@ -0,0 +1,107 @@ +use crate::client::KeycloakClient; +use crate::models::{GroupRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_groups( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let groups_dir = workspace_dir.join("groups"); + if async_fs::try_exists(&groups_dir).await? { + let existing_groups = client.get_groups().await?; + let existing_groups_map: HashMap = existing_groups + .into_iter() + .filter_map(|g| g.get_identity().map(|id| (id, g))) + .collect(); + let existing_groups_map = Arc::new(existing_groups_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&groups_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_groups_map = existing_groups_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_group: GroupRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_group + .get_identity() + .context(format!("Failed to get identity for group in {:?}", path))?; + let remote = existing_groups_map.get(&identity).cloned(); + + Ok::<(GroupRepresentation, PathBuf, Option), anyhow::Error>(( + local_group, + path, + remote, + )) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_group, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_group.id.is_none() { + remote_clone.id = None; + } + print_diff( + &format!("Group {}", local_group.get_name()), + Some(&remote_clone), + &local_group, + changes_only, + "group", + )? + } else { + println!( + "\n{} Will create Group: {}", + Emoji("✨", ""), + local_group.get_name() + ); + print_diff( + &format!("Group {}", local_group.get_name()), + None::<&GroupRepresentation>, + &local_group, + changes_only, + "group", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/idps.rs b/src/plan/idps.rs new file mode 100644 index 0000000..58e5c51 --- /dev/null +++ b/src/plan/idps.rs @@ -0,0 +1,110 @@ +use crate::client::KeycloakClient; +use crate::models::{IdentityProviderRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_identity_providers( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let idps_dir = workspace_dir.join("identity-providers"); + if async_fs::try_exists(&idps_dir).await? { + let existing_idps = client.get_identity_providers().await?; + let existing_idps_map: HashMap = existing_idps + .into_iter() + .filter_map(|i| i.get_identity().map(|id| (id, i))) + .collect(); + let existing_idps_map = Arc::new(existing_idps_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&idps_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_idps_map = existing_idps_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_idp: IdentityProviderRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_idp + .get_identity() + .context(format!("Failed to get identity for IDP in {:?}", path))?; + let remote = existing_idps_map.get(&identity).cloned(); + + Ok::< + ( + IdentityProviderRepresentation, + PathBuf, + Option, + ), + anyhow::Error, + >((local_idp, path, remote)) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_idp, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_idp.internal_id.is_none() { + remote_clone.internal_id = None; + } + print_diff( + &format!("IdentityProvider {}", local_idp.get_name()), + Some(&remote_clone), + &local_idp, + changes_only, + "idp", + )? + } else { + println!( + "\n{} Will create IdentityProvider: {}", + Emoji("✨", ""), + local_idp.get_name() + ); + print_diff( + &format!("IdentityProvider {}", local_idp.get_name()), + None::<&IdentityProviderRepresentation>, + &local_idp, + changes_only, + "idp", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/mod.rs b/src/plan/mod.rs new file mode 100644 index 0000000..db1dc26 --- /dev/null +++ b/src/plan/mod.rs @@ -0,0 +1,264 @@ +pub mod actions; +pub mod clients; +pub mod components; +pub mod flows; +pub mod groups; +pub mod idps; +pub mod realm; +pub mod roles; +pub mod scopes; +pub mod users; + +use crate::client::KeycloakClient; +use crate::utils::secrets::obfuscate_secrets; + +use anyhow::Result; +use console::{Emoji, Style, style}; +use serde::Serialize; +use similar::{ChangeTag, TextDiff}; +use std::collections::HashMap; +use std::env; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs as async_fs; + +pub static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); +pub static ACTION: Emoji<'_, '_> = Emoji("🔍 ", "> "); + +pub async fn run( + client: &KeycloakClient, + workspace_dir: PathBuf, + changes_only: bool, + interactive: bool, + realms_to_plan: &[String], +) -> Result<()> { + if !workspace_dir.exists() { + anyhow::bail!("Input directory {:?} does not exist", workspace_dir); + } + + // Load .secrets from input directory if it exists + let env_path = workspace_dir.join(".secrets"); + if env_path.exists() { + dotenvy::from_path(&env_path).ok(); + } + + let env_vars = Arc::new(env::vars().collect::>()); + + let realms = if realms_to_plan.is_empty() { + let mut dirs = Vec::new(); + let mut entries = async_fs::read_dir(&workspace_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await?.is_dir() { + dirs.push(entry.file_name().to_string_lossy().to_string()); + } + } + dirs + } else { + realms_to_plan.to_vec() + }; + + if realms.is_empty() { + println!( + "{} {}", + WARN, + style(format!("No realms found to plan in {:?}", workspace_dir)).yellow() + ); + return Ok(()); + } + + let mut changed_files = Vec::new(); + for realm_name in realms { + let mut realm_client = client.clone(); + realm_client.set_target_realm(realm_name.clone()); + let realm_dir = workspace_dir.join(&realm_name); + println!( + "\n{} {}", + ACTION, + style(format!("Planning changes for realm: {}", realm_name)) + .cyan() + .bold() + ); + plan_single_realm( + &realm_client, + realm_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + &mut changed_files, + ) + .await?; + } + + let plan_file = workspace_dir.join(".kcdplan"); + if changed_files.is_empty() { + if async_fs::try_exists(&plan_file).await? { + async_fs::remove_file(&plan_file).await?; + } + } else { + let content = serde_json::to_string_pretty(&changed_files)?; + async_fs::write(&plan_file, content).await?; + } + + Ok(()) +} + +async fn plan_single_realm( + client: &KeycloakClient, + workspace_dir: PathBuf, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + realm::plan_realm( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + roles::plan_roles( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + clients::plan_clients( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + idps::plan_identity_providers( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + scopes::plan_client_scopes( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + groups::plan_groups( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + users::plan_users( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + flows::plan_authentication_flows( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + actions::plan_required_actions( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + ) + .await?; + + components::plan_components_or_keys( + client, + &workspace_dir, + changes_only, + interactive, + "components", + Arc::clone(&env_vars), + changed_files, + ) + .await?; + components::plan_components_or_keys( + client, + &workspace_dir, + changes_only, + interactive, + "keys", + Arc::clone(&env_vars), + changed_files, + ) + .await?; + components::check_keys_drift(client, changes_only).await?; + + Ok(()) +} + +pub fn print_diff( + name: &str, + old: Option<&T>, + new: &T, + changes_only: bool, + prefix: &str, +) -> Result { + let old_yaml = if let Some(o) = old { + let mut val = serde_json::to_value(o)?; + obfuscate_secrets(&mut val, prefix); + crate::utils::to_sorted_yaml(&val)? + } else { + String::new() + }; + + let mut new_val = serde_json::to_value(new)?; + obfuscate_secrets(&mut new_val, prefix); + let new_yaml = crate::utils::to_sorted_yaml(&new_val)?; + + let diff = TextDiff::from_lines(&old_yaml, &new_yaml); + let changed = diff.ratio() < 1.0; + + if changed { + println!("\n{} Changes for {}:", Emoji("📝", ""), name); + for change in diff.iter_all_changes() { + let (sign, style) = match change.tag() { + ChangeTag::Delete => ("-", Style::new().red()), + ChangeTag::Insert => ("+", Style::new().green()), + ChangeTag::Equal => (" ", Style::new().dim()), + }; + print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change)); + } + } else if !changes_only { + println!("{} No changes for {}", Emoji("✅", ""), name); + } + Ok(changed) +} diff --git a/src/plan/realm.rs b/src/plan/realm.rs new file mode 100644 index 0000000..4f75b15 --- /dev/null +++ b/src/plan/realm.rs @@ -0,0 +1,64 @@ +use crate::client::KeycloakClient; +use crate::models::RealmRepresentation; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_realm( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let realm_path = workspace_dir.join("realm.yaml"); + if async_fs::try_exists(&realm_path).await? { + let content = async_fs::read_to_string(&realm_path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", realm_path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_realm: RealmRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", realm_path))?; + + // We handle the case where remote realm fetch might fail (e.g. if we are creating it) + // by treating it as None (creation). However, usually plan is run against existing realm. + let remote_realm = match client.get_realm().await { + Ok(r) => Some(r), + Err(e) => { + // Check if it's a 404 (Not Found) + if e.to_string().contains("404") { + None + } else { + return Err(e); + } + } + }; + + if print_diff( + "Realm", + remote_realm.as_ref(), + &local_realm, + changes_only, + "realm", + )? { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(realm_path); + } + } + } + Ok(()) +} diff --git a/src/plan/roles.rs b/src/plan/roles.rs new file mode 100644 index 0000000..1b2578c --- /dev/null +++ b/src/plan/roles.rs @@ -0,0 +1,107 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, RoleRepresentation}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_roles( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let roles_dir = workspace_dir.join("roles"); + if async_fs::try_exists(&roles_dir).await? { + let existing_roles = client.get_roles().await?; + let existing_roles_map: HashMap = existing_roles + .into_iter() + .filter_map(|r| r.get_identity().map(|id| (id, r))) + .collect(); + let existing_roles_map = Arc::new(existing_roles_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&roles_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_roles_map = existing_roles_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_role: RoleRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_role + .get_identity() + .context(format!("Failed to get identity for role in {:?}", path))?; + let remote = existing_roles_map.get(&identity).cloned(); + + Ok::<(RoleRepresentation, PathBuf, Option), anyhow::Error>( + (local_role, path, remote), + ) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_role, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + // Ignore ID differences if local doesn't specify it + if local_role.id.is_none() { + remote_clone.id = None; + remote_clone.container_id = None; + } + print_diff( + &format!("Role {}", local_role.get_name()), + Some(&remote_clone), + &local_role, + changes_only, + "role", + )? + } else { + println!( + "\n{} Will create Role: {}", + Emoji("✨", ""), + local_role.get_name() + ); + print_diff( + &format!("Role {}", local_role.get_name()), + None::<&RoleRepresentation>, + &local_role, + changes_only, + "role", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/scopes.rs b/src/plan/scopes.rs new file mode 100644 index 0000000..9ee8a24 --- /dev/null +++ b/src/plan/scopes.rs @@ -0,0 +1,110 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientScopeRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_client_scopes( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let scopes_dir = workspace_dir.join("client-scopes"); + if async_fs::try_exists(&scopes_dir).await? { + let existing_scopes = client.get_client_scopes().await?; + let existing_scopes_map: HashMap = existing_scopes + .into_iter() + .filter_map(|s| s.get_identity().map(|id| (id, s))) + .collect(); + let existing_scopes_map = Arc::new(existing_scopes_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&scopes_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_scopes_map = existing_scopes_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_scope: ClientScopeRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_scope + .get_identity() + .context(format!("Failed to get identity for scope in {:?}", path))?; + let remote = existing_scopes_map.get(&identity).cloned(); + + Ok::< + ( + ClientScopeRepresentation, + PathBuf, + Option, + ), + anyhow::Error, + >((local_scope, path, remote)) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_scope, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_scope.id.is_none() { + remote_clone.id = None; + } + print_diff( + &format!("ClientScope {}", local_scope.get_name()), + Some(&remote_clone), + &local_scope, + changes_only, + "client_scope", + )? + } else { + println!( + "\n{} Will create ClientScope: {}", + Emoji("✨", ""), + local_scope.get_name() + ); + print_diff( + &format!("ClientScope {}", local_scope.get_name()), + None::<&ClientScopeRepresentation>, + &local_scope, + changes_only, + "client_scope", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/plan/users.rs b/src/plan/users.rs new file mode 100644 index 0000000..4c2c6d8 --- /dev/null +++ b/src/plan/users.rs @@ -0,0 +1,105 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, UserRepresentation}; +use crate::utils::secrets::substitute_secrets; +use anyhow::{Context, Result}; +use console::Emoji; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs as async_fs; + +use super::print_diff; + +pub async fn plan_users( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, +) -> Result<()> { + let users_dir = workspace_dir.join("users"); + if async_fs::try_exists(&users_dir).await? { + let existing_users = client.get_users().await?; + let existing_users_map: HashMap = existing_users + .into_iter() + .filter_map(|u| u.get_identity().map(|id| (id, u))) + .collect(); + let existing_users_map = Arc::new(existing_users_map); + + let mut set = tokio::task::JoinSet::new(); + let mut entries = async_fs::read_dir(&users_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + let env_vars = env_vars.clone(); + let existing_users_map = existing_users_map.clone(); + + set.spawn(async move { + let content = async_fs::read_to_string(&path).await?; + let mut val: serde_json::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML file: {:?}", path))?; + substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; + let local_user: UserRepresentation = serde_json::from_value(val) + .with_context(|| format!("Failed to deserialize YAML file: {:?}", path))?; + + let identity = local_user + .get_identity() + .context(format!("Failed to get identity for user in {:?}", path))?; + let remote = existing_users_map.get(&identity).cloned(); + + Ok::<(UserRepresentation, PathBuf, Option), anyhow::Error>( + (local_user, path, remote), + ) + }); + } + } + + while let Some(res) = set.join_next().await { + let (local_user, path, remote) = res??; + + let changed = if let Some(remote) = remote { + let mut remote_clone = remote.clone(); + if local_user.id.is_none() { + remote_clone.id = None; + } + print_diff( + &format!("User {}", local_user.get_name()), + Some(&remote_clone), + &local_user, + changes_only, + "user", + )? + } else { + println!( + "\n{} Will create User: {}", + Emoji("✨", ""), + local_user.get_name() + ); + print_diff( + &format!("User {}", local_user.get_name()), + None::<&UserRepresentation>, + &local_user, + changes_only, + "user", + )? + }; + + if changed { + let mut include = true; + if interactive { + include = + dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Include this change in the plan?") + .default(true) + .interact()?; + } + if include { + changed_files.push(path); + } + } + } + } + Ok(()) +} diff --git a/src/validate.rs b/src/validate.rs index b51447e..eb657bf 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -8,42 +8,57 @@ use anyhow::{Context, Result}; use console::{Emoji, style}; use serde::de::DeserializeOwned; use std::collections::HashSet; -use std::fs; use std::path::{Path, PathBuf}; +use tokio::fs; +use tokio::task::JoinSet; static CHECK: Emoji<'_, '_> = Emoji("✅ ", "√ "); static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", "> "); static SUCCESS: Emoji<'_, '_> = Emoji("🎉 ", "* "); static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); -fn read_yaml_files(dir: &Path, file_type: &str) -> Result> { +async fn read_yaml_files( + dir: &Path, + file_type: &str, +) -> Result> { let mut results = Vec::new(); - if dir.exists() { - for entry in fs::read_dir(dir)? { - let entry = entry?; + if fs::try_exists(dir).await? { + let mut entries = fs::read_dir(dir).await?; + let mut join_set = JoinSet::new(); + let file_type_str = file_type.to_string(); + + while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "yaml") { - let content = fs::read_to_string(&path) - .context(format!("Failed to read {} file {:?}", file_type, path))?; - let item: T = serde_yaml::from_str(&content) - .context(format!("Failed to parse {} file {:?}", file_type, path))?; - results.push((path, item)); + let ft = file_type_str.clone(); + join_set.spawn(async move { + let content = fs::read_to_string(&path) + .await + .context(format!("Failed to read {} file {:?}", ft, path))?; + let item: T = serde_yaml::from_str(&content) + .context(format!("Failed to parse {} file {:?}", ft, path))?; + Ok::<(PathBuf, T), anyhow::Error>((path, item)) + }); } } + + while let Some(res) = join_set.join_next().await { + results.push(res??); + } } Ok(results) } -pub fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> { - if !workspace_dir.exists() { +pub async fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> { + if !fs::try_exists(&workspace_dir).await? { anyhow::bail!("Input directory {:?} does not exist", workspace_dir); } let realms = if realms_to_validate.is_empty() { let mut dirs = Vec::new(); - for entry in fs::read_dir(&workspace_dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { + let mut entries = fs::read_dir(&workspace_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await?.is_dir() { dirs.push(entry.file_name().to_string_lossy().to_string()); } } @@ -74,7 +89,7 @@ pub fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> .bold() ); let realm_dir = workspace_dir.join(realm_name); - validate_realm(realm_dir)?; + validate_realm(realm_dir).await?; println!( " {} {}", SUCCESS, @@ -86,13 +101,15 @@ pub fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> Ok(()) } -fn validate_realm(workspace_dir: PathBuf) -> Result<()> { +async fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 1. Validate Realm let realm_path = workspace_dir.join("realm.yaml"); - if !realm_path.exists() { + if !fs::try_exists(&realm_path).await? { anyhow::bail!("realm.yaml not found in {:?}", workspace_dir); } - let realm_content = fs::read_to_string(&realm_path).context("Failed to read realm.yaml")?; + let realm_content = fs::read_to_string(&realm_path) + .await + .context("Failed to read realm.yaml")?; let realm: RealmRepresentation = serde_yaml::from_str(&realm_content).context("Failed to parse realm.yaml")?; @@ -109,7 +126,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 2. Validate Roles let roles_dir = workspace_dir.join("roles"); let mut role_names = HashSet::new(); - let roles: Vec<(PathBuf, RoleRepresentation)> = read_yaml_files(&roles_dir, "role")?; + let roles: Vec<(PathBuf, RoleRepresentation)> = read_yaml_files(&roles_dir, "role").await?; for (path, role) in &roles { if role.name.is_empty() { @@ -129,7 +146,8 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 3. Validate Clients let clients_dir = workspace_dir.join("clients"); - let clients: Vec<(PathBuf, ClientRepresentation)> = read_yaml_files(&clients_dir, "client")?; + let clients: Vec<(PathBuf, ClientRepresentation)> = + read_yaml_files(&clients_dir, "client").await?; for (path, client) in &clients { if client.client_id.is_none() || client.client_id.as_deref().unwrap_or("").is_empty() { @@ -145,7 +163,8 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 4. Validate Identity Providers let idps_dir = workspace_dir.join("identity-providers"); - let idps: Vec<(PathBuf, IdentityProviderRepresentation)> = read_yaml_files(&idps_dir, "idp")?; + let idps: Vec<(PathBuf, IdentityProviderRepresentation)> = + read_yaml_files(&idps_dir, "idp").await?; for (path, idp) in &idps { if idp.alias.is_none() || idp.alias.as_deref().unwrap_or("").is_empty() { @@ -168,7 +187,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 5. Validate Client Scopes let scopes_dir = workspace_dir.join("client-scopes"); let scopes: Vec<(PathBuf, ClientScopeRepresentation)> = - read_yaml_files(&scopes_dir, "client-scope")?; + read_yaml_files(&scopes_dir, "client-scope").await?; for (path, scope) in &scopes { if scope.name.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Client Scope name is missing or empty in {:?}", path); @@ -183,7 +202,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 6. Validate Groups let groups_dir = workspace_dir.join("groups"); - let groups: Vec<(PathBuf, GroupRepresentation)> = read_yaml_files(&groups_dir, "group")?; + let groups: Vec<(PathBuf, GroupRepresentation)> = read_yaml_files(&groups_dir, "group").await?; for (path, group) in &groups { if group.name.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Group name is missing or empty in {:?}", path); @@ -198,7 +217,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 7. Validate Users let users_dir = workspace_dir.join("users"); - let users: Vec<(PathBuf, UserRepresentation)> = read_yaml_files(&users_dir, "user")?; + let users: Vec<(PathBuf, UserRepresentation)> = read_yaml_files(&users_dir, "user").await?; for (path, user) in &users { if user.username.as_deref().unwrap_or("").is_empty() { anyhow::bail!("User username is missing or empty in {:?}", path); @@ -214,7 +233,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 8. Validate Authentication Flows let flows_dir = workspace_dir.join("authentication-flows"); let flows: Vec<(PathBuf, AuthenticationFlowRepresentation)> = - read_yaml_files(&flows_dir, "authentication-flow")?; + read_yaml_files(&flows_dir, "authentication-flow").await?; for (path, flow) in &flows { if flow.alias.as_deref().unwrap_or("").is_empty() { anyhow::bail!( @@ -233,7 +252,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 9. Validate Required Actions let actions_dir = workspace_dir.join("required-actions"); let actions: Vec<(PathBuf, RequiredActionProviderRepresentation)> = - read_yaml_files(&actions_dir, "required-action")?; + read_yaml_files(&actions_dir, "required-action").await?; for (path, action) in &actions { if action.alias.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Required Action alias is missing or empty in {:?}", path); @@ -255,9 +274,9 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 10. Validate Components and Keys for dir_name in ["components", "keys"].iter() { let dir = workspace_dir.join(dir_name); - if fs::exists(&dir)? { + if fs::try_exists(&dir).await? { let components: Vec<(PathBuf, ComponentRepresentation)> = - read_yaml_files(&dir, dir_name)?; + read_yaml_files(&dir, dir_name).await?; for (path, component) in &components { if let Some(name) = &component.name && name.is_empty() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 939319f..969477d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,5 @@ use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tokio::net::TcpListener; #[derive(Deserialize, Debug)] @@ -12,12 +12,6 @@ pub struct TokenRequest { pub client_secret: Option, } -#[derive(Serialize)] -pub struct TokenResponse { - pub access_token: String, - pub expires_in: i32, -} - pub async fn start_mock_server() -> String { let app = Router::new() .route( diff --git a/tests/coverage_test.rs b/tests/coverage_test.rs index 2b5d33c..03801e7 100644 --- a/tests/coverage_test.rs +++ b/tests/coverage_test.rs @@ -70,7 +70,7 @@ async fn test_plan_edge_cases() { // 6. Test with invalid YAML fs::write(realm_dir.join("invalid.yaml"), "invalid: [yaml").unwrap(); - let res = plan::run( + let _res = plan::run( &client, workspace_dir.clone(), false, @@ -259,3 +259,37 @@ async fn test_clean_edge_cases() { .unwrap(); assert!(!file_path.exists()); } + +#[tokio::test] +async fn test_validate_edge_cases() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + + // 1. Test run with non-existent directory + let res = app::validate::run(workspace_dir.join("non-existent"), &[]).await; + assert!(res.is_err()); + + // 2. Test run with empty directory (no realms) + fs::create_dir_all(&workspace_dir).unwrap(); + let res = app::validate::run(workspace_dir.clone(), &[]).await; + assert!(res.is_ok()); + + // 3. Test auto-discovery of realms for validation + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir(&realm_dir).unwrap(); + let realm = RealmRepresentation { + realm: "test-realm".to_string(), + enabled: Some(true), + display_name: Some("Test Realm".to_string()), + extra: std::collections::HashMap::new(), + }; + fs::write( + realm_dir.join("realm.yaml"), + serde_yaml::to_string(&realm).unwrap(), + ) + .unwrap(); + + app::validate::run(workspace_dir.clone(), &[]) + .await + .unwrap(); +} diff --git a/tests/lib_test.rs b/tests/lib_test.rs new file mode 100644 index 0000000..f73cde4 --- /dev/null +++ b/tests/lib_test.rs @@ -0,0 +1,40 @@ +use app::args::{Cli, Commands}; +use app::init_client; +use app::run_app; +use std::path::PathBuf; + +#[tokio::test] +async fn test_init_client_fail() { + let cli = Cli { + server: "http://invalid".to_string(), + client_id: "admin-cli".to_string(), + client_secret: None, + user: Some("admin".to_string()), + password: Some("password".to_string()), + realms: vec![], + command: Commands::Validate { + workspace: PathBuf::from("."), + }, + }; + + let res = init_client(&cli).await; + assert!(res.is_err()); +} + +#[tokio::test] +async fn test_run_app_validate_non_existent() { + let cli = Cli { + server: "http://localhost:8080".to_string(), + client_id: "admin-cli".to_string(), + client_secret: None, + user: None, + password: None, + realms: vec![], + command: Commands::Validate { + workspace: PathBuf::from("non-existent-dir-123"), + }, + }; + + let res = run_app(cli).await; + assert!(res.is_err()); +} diff --git a/tests/models_coverage_test.rs b/tests/models_coverage_test.rs index e7ccfb1..3ea3a18 100644 --- a/tests/models_coverage_test.rs +++ b/tests/models_coverage_test.rs @@ -46,7 +46,7 @@ fn test_models_resource_trait() { service_accounts_enabled: None, extra: HashMap::new(), }; - assert_eq!(client.get_identity(), Some("id2".to_string())); + assert_eq!(client.get_identity(), Some("cid".to_string())); assert_eq!(client.get_name(), "cid".to_string()); let role = RoleRepresentation { @@ -58,7 +58,7 @@ fn test_models_resource_trait() { client_role: false, extra: HashMap::new(), }; - assert_eq!(role.get_identity(), Some("id3".to_string())); + assert_eq!(role.get_identity(), Some("rname".to_string())); assert_eq!(role.get_name(), "rname".to_string()); let group = GroupRepresentation { @@ -68,7 +68,7 @@ fn test_models_resource_trait() { sub_groups: None, extra: HashMap::new(), }; - assert_eq!(group.get_identity(), Some("id4".to_string())); + assert_eq!(group.get_identity(), Some("gname".to_string())); assert_eq!(group.get_name(), "gname".to_string()); let user = UserRepresentation { @@ -82,7 +82,7 @@ fn test_models_resource_trait() { credentials: None, extra: HashMap::new(), }; - assert_eq!(user.get_identity(), Some("id5".to_string())); + assert_eq!(user.get_identity(), Some("uname".to_string())); assert_eq!(user.get_name(), "uname".to_string()); let scope = ClientScopeRepresentation { @@ -93,7 +93,7 @@ fn test_models_resource_trait() { attributes: None, extra: HashMap::new(), }; - assert_eq!(scope.get_identity(), Some("id6".to_string())); + assert_eq!(scope.get_identity(), Some("sname".to_string())); assert_eq!(scope.get_name(), "sname".to_string()); let flow = AuthenticationFlowRepresentation { @@ -106,7 +106,7 @@ fn test_models_resource_trait() { authentication_executions: None, extra: HashMap::new(), }; - assert_eq!(flow.get_identity(), Some("id7".to_string())); + assert_eq!(flow.get_identity(), Some("falias".to_string())); assert_eq!(flow.get_name(), "falias".to_string()); let action = RequiredActionProviderRepresentation { @@ -132,6 +132,6 @@ fn test_models_resource_trait() { config: None, extra: HashMap::new(), }; - assert_eq!(comp.get_identity(), Some("id8".to_string())); + assert_eq!(comp.get_identity(), Some("cname".to_string())); assert_eq!(comp.get_name(), "cname".to_string()); } diff --git a/tests/plan_components_test.rs b/tests/plan_components_test.rs new file mode 100644 index 0000000..845521b --- /dev/null +++ b/tests/plan_components_test.rs @@ -0,0 +1,64 @@ +use app::client::KeycloakClient; +use app::plan::components::{check_keys_drift, plan_components_or_keys}; +use std::collections::HashMap; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::fs; + +#[tokio::test] +async fn test_plan_components_no_dir() { + let client = KeycloakClient::new("http://localhost:8080".to_string()); + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let mut changed_files = Vec::new(); + let env_vars = Arc::new(HashMap::new()); + + // Should not fail if directory doesn't exist + let res = plan_components_or_keys( + &client, + workspace_dir, + false, + false, + "non-existent", + env_vars, + &mut changed_files, + ) + .await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_check_keys_drift_fail() { + // Client that will fail to connect + let client = KeycloakClient::new("http://localhost:1".to_string()); + let res = check_keys_drift(&client, true).await; + // check_keys_drift ignores error if not available + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_plan_components_with_invalid_yaml() { + let client = KeycloakClient::new("http://localhost:8080".to_string()); + let dir = tempdir().unwrap(); + let workspace_dir = dir.path(); + let components_dir = workspace_dir.join("components"); + fs::create_dir_all(&components_dir).await.unwrap(); + fs::write(components_dir.join("bad.yaml"), "invalid: [ :") + .await + .unwrap(); + + let mut changed_files = Vec::new(); + let env_vars = Arc::new(HashMap::new()); + + let res = plan_components_or_keys( + &client, + workspace_dir, + false, + false, + "components", + env_vars, + &mut changed_files, + ) + .await; + assert!(res.is_err()); +} diff --git a/tests/plan_coverage_test.rs b/tests/plan_coverage_test.rs new file mode 100644 index 0000000..39d5ed4 --- /dev/null +++ b/tests/plan_coverage_test.rs @@ -0,0 +1,128 @@ +mod common; +use app::client::KeycloakClient; +use app::plan; +use common::start_mock_server; +use std::fs; +use tempfile::tempdir; + +#[tokio::test] +async fn test_plan_non_existent_workspace() { + let mock_url = start_mock_server().await; + let client = KeycloakClient::new(mock_url); + let res = plan::run( + &client, + std::path::PathBuf::from("non-existent-123"), + false, + false, + &[], + ) + .await; + assert!(res.is_err()); +} + +#[tokio::test] +async fn test_plan_empty_workspace() { + let mock_url = start_mock_server().await; + let client = KeycloakClient::new(mock_url); + let dir = tempdir().unwrap(); + let res = plan::run(&client, dir.path().to_path_buf(), false, false, &[]).await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_plan_with_secrets_file() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client + .login("admin-cli", Some("secret"), None, None) + .await + .unwrap(); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + fs::write(workspace_dir.join(".secrets"), "MY_SECRET=value").unwrap(); + + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + let res = plan::run( + &client, + workspace_dir, + false, + false, + &["test-realm".to_string()], + ) + .await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_plan_cleanup_old_plan_file() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client + .login("admin-cli", Some("secret"), None, None) + .await + .unwrap(); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let plan_file = workspace_dir.join(".kcdplan"); + fs::write(&plan_file, "[]").unwrap(); + + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + // No changes, so it should remove .kcdplan + let res = plan::run( + &client, + workspace_dir.clone(), + false, + false, + &["test-realm".to_string()], + ) + .await; + assert!(res.is_ok()); + assert!(!plan_file.exists()); +} + +#[tokio::test] +async fn test_plan_realm_not_found_remote() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("new-realm".to_string()); + client + .login("admin-cli", Some("secret"), None, None) + .await + .unwrap(); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("new-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + let realm = app::models::RealmRepresentation { + realm: "new-realm".to_string(), + enabled: Some(true), + display_name: Some("New Realm".to_string()), + extra: std::collections::HashMap::new(), + }; + fs::write( + realm_dir.join("realm.yaml"), + serde_yaml::to_string(&realm).unwrap(), + ) + .unwrap(); + + // "new-realm" will return 404 from mock server + let res = plan::run( + &client, + workspace_dir, + false, + false, + &["new-realm".to_string()], + ) + .await; + assert!(res.is_ok()); +} diff --git a/tests/plan_extended_test.rs b/tests/plan_extended_test.rs new file mode 100644 index 0000000..3c0d60f --- /dev/null +++ b/tests/plan_extended_test.rs @@ -0,0 +1,140 @@ +mod common; +use app::client::KeycloakClient; +use app::models::{ComponentRepresentation, RealmRepresentation}; +use app::plan; +use common::start_mock_server; +use std::fs; +use tempfile::tempdir; + +#[tokio::test] +async fn test_plan_keys_and_extended() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client + .login("admin-cli", Some("secret"), None, None) + .await + .expect("Login failed"); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + // 1. Create realm.yaml + let realm = RealmRepresentation { + realm: "test-realm".to_string(), + enabled: Some(true), + display_name: Some("Test Realm".to_string()), + extra: std::collections::HashMap::new(), + }; + fs::write( + realm_dir.join("realm.yaml"), + serde_yaml::to_string(&realm).unwrap(), + ) + .unwrap(); + + // 2. Create keys directory and a key component + let keys_dir = realm_dir.join("keys"); + fs::create_dir(&keys_dir).unwrap(); + + // This matches NOTHING in the mock server (mock server returns kid "key-1" in /keys, but /components returns component-1) + // Actually mock server /components returns: + // { "id": "c1", "name": "component-1", "providerId": "ldap", "providerType": "org.keycloak.storage.UserStorageProvider" } + + let key_component = ComponentRepresentation { + id: None, + name: Some("new-key".to_string()), + provider_id: Some("rsa-generated".to_string()), + provider_type: Some("org.keycloak.keys.KeyProvider".to_string()), + sub_type: None, + parent_id: None, + config: None, + extra: std::collections::HashMap::new(), + }; + fs::write( + keys_dir.join("new-key.yaml"), + serde_yaml::to_string(&key_component).unwrap(), + ) + .unwrap(); + + // 3. Create a component with ID already set (to hit local_component.id.is_some() branch) + let components_dir = realm_dir.join("components"); + fs::create_dir(&components_dir).unwrap(); + let existing_comp = ComponentRepresentation { + id: Some("c1".to_string()), + name: Some("component-1".to_string()), + provider_id: Some("ldap".to_string()), + provider_type: Some("org.keycloak.storage.UserStorageProvider".to_string()), + sub_type: None, + parent_id: None, + config: None, + extra: std::collections::HashMap::new(), + }; + fs::write( + components_dir.join("component-1.yaml"), + serde_yaml::to_string(&existing_comp).unwrap(), + ) + .unwrap(); + + // Run plan with changes_only=true to trigger check_keys_drift + plan::run( + &client, + workspace_dir.clone(), + true, + false, + &["test-realm".to_string()], + ) + .await + .expect("Plan failed"); + + // Run plan with changes_only=false + plan::run( + &client, + workspace_dir.clone(), + false, + false, + &["test-realm".to_string()], + ) + .await + .expect("Plan failed"); +} + +#[tokio::test] +async fn test_plan_substitute_secrets_error() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client.set_token("mock".to_string()); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + // Create a client with a missing environment variable + let clients_dir = realm_dir.join("clients"); + fs::create_dir(&clients_dir).unwrap(); + fs::write( + clients_dir.join("error-client.yaml"), + "clientId: error-client\nsecret: '${KEYCLOAK_MISSING_VAR}'\n", + ) + .unwrap(); + + let res = plan::run( + &client, + workspace_dir, + false, + false, + &["test-realm".to_string()], + ) + .await; + + // Should fail due to missing environment variable + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Missing required environment variable") + ); +} diff --git a/tests/run_app_test.rs b/tests/run_app_test.rs new file mode 100644 index 0000000..b72f747 --- /dev/null +++ b/tests/run_app_test.rs @@ -0,0 +1,169 @@ +mod common; +use anyhow::Result; +use app::args::{Cli, Commands}; +use app::run_app; +use tempfile::tempdir; + +#[tokio::test] +async fn test_run_app_validate() -> Result<()> { + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + let cli = Cli { + command: Commands::Validate { workspace }, + server: "http://localhost:8080".to_string(), + realms: vec![], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: None, + }; + + run_app(cli).await?; + Ok(()) +} + +#[tokio::test] +async fn test_run_app_inspect() -> Result<()> { + use common::start_mock_server; + let mock_url = start_mock_server().await; + + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + let cli = Cli { + command: Commands::Inspect { + workspace, + yes: true, + }, + server: mock_url, + realms: vec!["test-realm".to_string()], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: Some("secret".to_string()), + }; + + run_app(cli).await?; + Ok(()) +} + +#[tokio::test] +async fn test_run_app_apply() -> Result<()> { + use common::start_mock_server; + let mock_url = start_mock_server().await; + + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + let realm_dir = workspace.join("test-realm"); + std::fs::create_dir_all(&realm_dir).unwrap(); + std::fs::write(realm_dir.join("realm.yaml"), "realm: test-realm\n").unwrap(); + + let cli = Cli { + command: Commands::Apply { + workspace, + yes: true, + }, + server: mock_url, + realms: vec!["test-realm".to_string()], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: Some("secret".to_string()), + }; + + run_app(cli).await?; + Ok(()) +} + +#[tokio::test] +async fn test_run_app_plan() -> Result<()> { + use common::start_mock_server; + let mock_url = start_mock_server().await; + + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + let cli = Cli { + command: Commands::Plan { + workspace, + changes_only: false, + interactive: false, + }, + server: mock_url, + realms: vec![], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: Some("secret".to_string()), + }; + + run_app(cli).await?; + Ok(()) +} + +/* +#[tokio::test] +async fn test_run_app_cli() -> Result<()> { + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + let cli = Cli { + command: Commands::Cli { workspace }, + server: "http://localhost:8080".to_string(), + realms: vec![], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: None, + }; + + run_app(cli).await?; + Ok(()) +} +*/ + +#[tokio::test] +async fn test_run_app_clean() -> Result<()> { + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + let cli = Cli { + command: Commands::Clean { + workspace, + yes: true, + }, + server: "http://localhost:8080".to_string(), + realms: vec![], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: None, + }; + + run_app(cli).await?; + Ok(()) +} + +#[tokio::test] +async fn test_run_app_drift() -> Result<()> { + // We need a mock server for drift because it calls init_client + use common::start_mock_server; + let mock_url = start_mock_server().await; + + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + let cli = Cli { + command: Commands::Drift { workspace }, + server: mock_url, + realms: vec![], + user: None, + password: None, + client_id: "admin-cli".to_string(), + client_secret: Some("secret".to_string()), + }; + + run_app(cli).await?; + Ok(()) +} diff --git a/tests/ultimate_coverage_test.rs b/tests/ultimate_coverage_test.rs index 9f0e427..2e278b8 100644 --- a/tests/ultimate_coverage_test.rs +++ b/tests/ultimate_coverage_test.rs @@ -2,7 +2,6 @@ mod common; use app::client::KeycloakClient; use app::{apply, plan}; use common::start_mock_server; -use serde_json::json; use std::fs; use tempfile::tempdir; diff --git a/tests/validate_test.rs b/tests/validate_test.rs index 82494b8..680d6db 100644 --- a/tests/validate_test.rs +++ b/tests/validate_test.rs @@ -8,8 +8,8 @@ use app::validate; use std::fs; use tempfile::tempdir; -#[test] -fn test_validate() { +#[tokio::test] +async fn test_validate() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -28,12 +28,12 @@ fn test_validate() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_ok()); } -#[test] -fn test_validate_empty_role_name() { +#[tokio::test] +async fn test_validate_empty_role_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -72,7 +72,7 @@ fn test_validate_empty_role_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -82,8 +82,8 @@ fn test_validate_empty_role_name() { ); } -#[test] -fn test_validate_duplicate_role_name() { +#[tokio::test] +async fn test_validate_duplicate_role_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -138,7 +138,7 @@ fn test_validate_duplicate_role_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -148,14 +148,14 @@ fn test_validate_duplicate_role_name() { ); } -#[test] -fn test_validate_missing_realm() { +#[tokio::test] +async fn test_validate_missing_realm() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -165,8 +165,8 @@ fn test_validate_missing_realm() { ); } -#[test] -fn test_validate_empty_client_id() { +#[tokio::test] +async fn test_validate_empty_client_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -210,7 +210,7 @@ fn test_validate_empty_client_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -220,8 +220,8 @@ fn test_validate_empty_client_id() { ); } -#[test] -fn test_validate_empty_idp_alias() { +#[tokio::test] +async fn test_validate_empty_idp_alias() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -265,7 +265,7 @@ fn test_validate_empty_idp_alias() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -275,8 +275,8 @@ fn test_validate_empty_idp_alias() { ); } -#[test] -fn test_validate_empty_idp_provider_id() { +#[tokio::test] +async fn test_validate_empty_idp_provider_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -320,7 +320,7 @@ fn test_validate_empty_idp_provider_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -330,8 +330,8 @@ fn test_validate_empty_idp_provider_id() { ); } -#[test] -fn test_validate_empty_client_scope_name() { +#[tokio::test] +async fn test_validate_empty_client_scope_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -366,7 +366,7 @@ fn test_validate_empty_client_scope_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -376,8 +376,8 @@ fn test_validate_empty_client_scope_name() { ); } -#[test] -fn test_validate_empty_group_name() { +#[tokio::test] +async fn test_validate_empty_group_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -411,7 +411,7 @@ fn test_validate_empty_group_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -421,8 +421,8 @@ fn test_validate_empty_group_name() { ); } -#[test] -fn test_validate_empty_username() { +#[tokio::test] +async fn test_validate_empty_username() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -460,7 +460,7 @@ fn test_validate_empty_username() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -470,13 +470,14 @@ fn test_validate_empty_username() { ); } -#[test] -fn test_validate_empty_auth_flow_alias() { +#[tokio::test] +async fn test_validate_empty_auth_flow_alias() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); + // Create valid realm.yaml let realm = RealmRepresentation { realm: "test-realm".to_string(), enabled: Some(true), @@ -508,7 +509,7 @@ fn test_validate_empty_auth_flow_alias() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -518,13 +519,14 @@ fn test_validate_empty_auth_flow_alias() { ); } -#[test] -fn test_validate_empty_required_action_alias() { +#[tokio::test] +async fn test_validate_empty_required_action_alias() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); + // Create valid realm.yaml let realm = RealmRepresentation { realm: "test-realm".to_string(), enabled: Some(true), @@ -556,7 +558,7 @@ fn test_validate_empty_required_action_alias() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -566,13 +568,14 @@ fn test_validate_empty_required_action_alias() { ); } -#[test] -fn test_validate_empty_required_action_provider_id() { +#[tokio::test] +async fn test_validate_empty_required_action_provider_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); + // Create valid realm.yaml let realm = RealmRepresentation { realm: "test-realm".to_string(), enabled: Some(true), @@ -604,7 +607,7 @@ fn test_validate_empty_required_action_provider_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -614,13 +617,14 @@ fn test_validate_empty_required_action_provider_id() { ); } -#[test] -fn test_validate_empty_component_name() { +#[tokio::test] +async fn test_validate_empty_component_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); + // Create valid realm.yaml let realm = RealmRepresentation { realm: "test-realm".to_string(), enabled: Some(true), @@ -652,7 +656,7 @@ fn test_validate_empty_component_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -662,13 +666,14 @@ fn test_validate_empty_component_name() { ); } -#[test] -fn test_validate_missing_component_name() { +#[tokio::test] +async fn test_validate_missing_component_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); + // Create valid realm.yaml let realm = RealmRepresentation { realm: "test-realm".to_string(), enabled: Some(true), @@ -700,7 +705,7 @@ fn test_validate_missing_component_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!( result.is_ok(), "Validation should succeed for missing component name. Error: {:?}", @@ -708,13 +713,14 @@ fn test_validate_missing_component_name() { ); } -#[test] -fn test_validate_empty_component_provider_id() { +#[tokio::test] +async fn test_validate_empty_component_provider_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); + // Create valid realm.yaml let realm = RealmRepresentation { realm: "test-realm".to_string(), enabled: Some(true), @@ -746,7 +752,7 @@ fn test_validate_empty_component_provider_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -756,8 +762,8 @@ fn test_validate_empty_component_provider_id() { ); } -#[test] -fn test_validate_empty_realm_name() { +#[tokio::test] +async fn test_validate_empty_realm_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -776,7 +782,7 @@ fn test_validate_empty_realm_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result From e0a201a0d1f242838e19730f8f9fa5ec6b61f793 Mon Sep 17 00:00:00 2001 From: Fabio Falcinelli Date: Wed, 25 Mar 2026 22:10:30 +0100 Subject: [PATCH 2/3] Merged changes from Jules --- src/apply/clients.rs | 2 +- src/apply/components.rs | 10 ++++------ src/apply/groups.rs | 6 +++++- src/apply/mod.rs | 1 - src/inspect.rs | 3 ++- src/models.rs | 5 ++++- src/plan/actions.rs | 30 ++++++++++++++++++++---------- src/plan/components.rs | 22 ++++++++++++++++++---- src/plan/roles.rs | 6 +----- src/plan/users.rs | 6 +----- 10 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/apply/clients.rs b/src/apply/clients.rs index c022407..7383459 100644 --- a/src/apply/clients.rs +++ b/src/apply/clients.rs @@ -106,9 +106,9 @@ mod tests { use super::*; use crate::client::KeycloakClient; use axum::{ + Json, Router, http::StatusCode, routing::{get, put}, - Json, Router, }; use std::fs; use std::sync::Arc; diff --git a/src/apply/components.rs b/src/apply/components.rs index 77d90c6..a9c572d 100644 --- a/src/apply/components.rs +++ b/src/apply/components.rs @@ -21,12 +21,10 @@ pub async fn apply_components_or_keys( ) -> Result<()> { let components_dir = workspace_dir.join(dir_name); if async_fs::try_exists(&components_dir).await? { - let existing_components = client.get_components().await.with_context(|| { - format!( - "Failed to get components/keys for realm '{}'", - realm_name - ) - })?; + let existing_components = client + .get_components() + .await + .with_context(|| format!("Failed to get components/keys for realm '{}'", realm_name))?; let mut by_identity: HashMap = HashMap::new(); type ComponentKey = ( Option, diff --git a/src/apply/groups.rs b/src/apply/groups.rs index 68430bb..c7dd55b 100644 --- a/src/apply/groups.rs +++ b/src/apply/groups.rs @@ -184,7 +184,11 @@ mod tests { // 1. Test update failure call_count.store(0, std::sync::atomic::Ordering::SeqCst); let group_existing = groups_dir.join("existing.yaml"); - fs::write(group_existing, "name: Existing Group\nid: existing-id\npath: /existing-group").unwrap(); + fs::write( + group_existing, + "name: Existing Group\nid: existing-id\npath: /existing-group", + ) + .unwrap(); let res = apply_groups( &client, diff --git a/src/apply/mod.rs b/src/apply/mod.rs index b3cb10b..1cc85e4 100644 --- a/src/apply/mod.rs +++ b/src/apply/mod.rs @@ -343,4 +343,3 @@ async fn apply_single_realm( Ok(()) } - diff --git a/src/inspect.rs b/src/inspect.rs index 5677f75..3f4f407 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -245,7 +245,8 @@ async fn inspect_realm( all_secrets.lock().await.extend(local_secrets); let realm_path = workspace_dir.join("realm.yaml"); - write_if_changed_with_mutex(&realm_path, &realm_yaml, yes, Arc::clone(&prompt_mutex)).await?; + write_if_changed_with_mutex(&realm_path, &realm_yaml, yes, Arc::clone(&prompt_mutex)) + .await?; { let _lock = prompt_mutex.lock().await; println!( diff --git a/src/models.rs b/src/models.rs index 768b97e..68067e2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -277,7 +277,10 @@ pub struct GroupRepresentation { impl KeycloakResource for GroupRepresentation { fn get_identity(&self) -> Option { - self.path.clone().or_else(|| self.id.clone()).or_else(|| self.name.clone()) + self.path + .clone() + .or_else(|| self.id.clone()) + .or_else(|| self.name.clone()) } fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) diff --git a/src/plan/actions.rs b/src/plan/actions.rs index b5d9bba..963e541 100644 --- a/src/plan/actions.rs +++ b/src/plan/actions.rs @@ -21,10 +21,9 @@ pub async fn plan_required_actions( ) -> Result<()> { let actions_dir = workspace_dir.join("required-actions"); if async_fs::try_exists(&actions_dir).await? { - let existing_actions = client - .get_required_actions() - .await - .with_context(|| format!("Failed to get required actions for realm '{}'", realm_name))?; + let existing_actions = client.get_required_actions().await.with_context(|| { + format!("Failed to get required actions for realm '{}'", realm_name) + })?; let existing_actions_map: HashMap = existing_actions .into_iter() @@ -44,17 +43,28 @@ pub async fn plan_required_actions( set.spawn(async move { let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file {:?} in realm '{}'", path, realm_name))?; + let mut val: serde_json::Value = + serde_yaml::from_str(&content).with_context(|| { + format!( + "Failed to parse YAML file {:?} in realm '{}'", + path, realm_name + ) + })?; substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; let local_action: RequiredActionProviderRepresentation = serde_json::from_value(val).with_context(|| { - format!("Failed to deserialize YAML file {:?} in realm '{}'", path, realm_name) + format!( + "Failed to deserialize YAML file {:?} in realm '{}'", + path, realm_name + ) })?; - let identity = local_action - .get_identity() - .with_context(|| format!("Failed to get identity for action in {:?} in realm '{}'", path, realm_name))?; + let identity = local_action.get_identity().with_context(|| { + format!( + "Failed to get identity for action in {:?} in realm '{}'", + path, realm_name + ) + })?; let remote = existing_actions_map.get(&identity).cloned(); Ok::< diff --git a/src/plan/components.rs b/src/plan/components.rs index 85390fa..58945f6 100644 --- a/src/plan/components.rs +++ b/src/plan/components.rs @@ -66,11 +66,21 @@ pub async fn plan_components_or_keys( set.spawn(async move { let content = async_fs::read_to_string(&path).await?; - let mut val: serde_json::Value = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML file {:?} in realm '{}'", path, realm_name))?; + let mut val: serde_json::Value = + serde_yaml::from_str(&content).with_context(|| { + format!( + "Failed to parse YAML file {:?} in realm '{}'", + path, realm_name + ) + })?; substitute_secrets(&mut val, &env_vars).map_err(|e| anyhow::anyhow!(e))?; let local_component: ComponentRepresentation = serde_json::from_value(val) - .with_context(|| format!("Failed to deserialize YAML file {:?} in realm '{}'", path, realm_name))?; + .with_context(|| { + format!( + "Failed to deserialize YAML file {:?} in realm '{}'", + path, realm_name + ) + })?; let remote = if let Some(identity) = local_component.get_identity() { by_identity @@ -165,7 +175,11 @@ pub async fn plan_components_or_keys( Ok(()) } -pub async fn check_keys_drift(client: &KeycloakClient, changes_only: bool, realm_name: &str) -> Result<()> { +pub async fn check_keys_drift( + client: &KeycloakClient, + changes_only: bool, + realm_name: &str, +) -> Result<()> { if !changes_only { return Ok(()); } diff --git a/src/plan/roles.rs b/src/plan/roles.rs index 95ac0ed..93ec292 100644 --- a/src/plan/roles.rs +++ b/src/plan/roles.rs @@ -78,11 +78,7 @@ pub async fn plan_roles( "role", )? } else { - println!( - "\n{} Will create Role: {}", - SPARKLE, - local_role.get_name() - ); + println!("\n{} Will create Role: {}", SPARKLE, local_role.get_name()); print_diff( &format!("Role {}", local_role.get_name()), None::<&RoleRepresentation>, diff --git a/src/plan/users.rs b/src/plan/users.rs index f7f2b51..75818f5 100644 --- a/src/plan/users.rs +++ b/src/plan/users.rs @@ -76,11 +76,7 @@ pub async fn plan_users( "user", )? } else { - println!( - "\n{} Will create User: {}", - SPARKLE, - local_user.get_name() - ); + println!("\n{} Will create User: {}", SPARKLE, local_user.get_name()); print_diff( &format!("User {}", local_user.get_name()), None::<&UserRepresentation>, From 0c3e954d7c8264c0b3825e09424b1a9c6b17bd3c Mon Sep 17 00:00:00 2001 From: Fabio Falcinelli Date: Wed, 25 Mar 2026 22:32:40 +0100 Subject: [PATCH 3/3] Pass quality checks --- src/plan/components.rs | 15 +++++++-------- src/plan/mod.rs | 19 ++++++++++++++----- src/validate.rs | 8 ++++---- tests/plan_components_test.rs | 23 ++++++++++++++++++----- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/plan/components.rs b/src/plan/components.rs index 58945f6..0b7c6fc 100644 --- a/src/plan/components.rs +++ b/src/plan/components.rs @@ -10,13 +10,12 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::fs as async_fs; -use super::print_diff; +use super::{PlanOptions, print_diff}; pub async fn plan_components_or_keys( client: &KeycloakClient, workspace_dir: &Path, - changes_only: bool, - interactive: bool, + options: PlanOptions, dir_name: &str, env_vars: Arc>, changed_files: &mut Vec, @@ -134,7 +133,7 @@ pub async fn plan_components_or_keys( &format!("Component {}", local_component.get_name()), Some(&remote_clone), &local_component, - changes_only, + options.changes_only, prefix, )? } else { @@ -152,14 +151,14 @@ pub async fn plan_components_or_keys( &format!("Component {}", local_component.get_name()), None::<&ComponentRepresentation>, &local_component, - changes_only, + options.changes_only, prefix, )? }; if changed { let mut include = true; - if interactive { + if options.interactive { include = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) .with_prompt("Include this change in the plan?") @@ -177,10 +176,10 @@ pub async fn plan_components_or_keys( pub async fn check_keys_drift( client: &KeycloakClient, - changes_only: bool, + options: PlanOptions, realm_name: &str, ) -> Result<()> { - if !changes_only { + if !options.changes_only { return Ok(()); } diff --git a/src/plan/mod.rs b/src/plan/mod.rs index c649e06..cd36e16 100644 --- a/src/plan/mod.rs +++ b/src/plan/mod.rs @@ -23,6 +23,12 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::fs as async_fs; +#[derive(Debug, Clone, Copy)] +pub struct PlanOptions { + pub changes_only: bool, + pub interactive: bool, +} + pub async fn run( client: &KeycloakClient, workspace_dir: PathBuf, @@ -209,11 +215,15 @@ async fn plan_single_realm( ) .await?; + let options = PlanOptions { + changes_only, + interactive, + }; + components::plan_components_or_keys( client, &workspace_dir, - changes_only, - interactive, + options, "components", Arc::clone(&env_vars), changed_files, @@ -223,15 +233,14 @@ async fn plan_single_realm( components::plan_components_or_keys( client, &workspace_dir, - changes_only, - interactive, + options, "keys", Arc::clone(&env_vars), changed_files, realm_name, ) .await?; - components::check_keys_drift(client, changes_only, realm_name).await?; + components::check_keys_drift(client, options, realm_name).await?; Ok(()) } diff --git a/src/validate.rs b/src/validate.rs index 1ab7119..82b28c7 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -274,10 +274,10 @@ async fn validate_realm(workspace_dir: PathBuf) -> Result<()> { let components: Vec<(PathBuf, ComponentRepresentation)> = read_yaml_files(&dir, dir_name).await?; for (path, component) in &components { - if let Some(name) = &component.name { - if name.is_empty() { - anyhow::bail!("Component name is empty in {:?}", path); - } + if let Some(name) = &component.name + && name.is_empty() + { + anyhow::bail!("Component name is empty in {:?}", path); } if component.provider_id.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Component providerId is missing or empty in {:?}", path); diff --git a/tests/plan_components_test.rs b/tests/plan_components_test.rs index 61cd952..68d5cfd 100644 --- a/tests/plan_components_test.rs +++ b/tests/plan_components_test.rs @@ -1,4 +1,5 @@ use app::client::KeycloakClient; +use app::plan::PlanOptions; use app::plan::components::{check_keys_drift, plan_components_or_keys}; use std::collections::HashMap; use std::sync::Arc; @@ -13,12 +14,16 @@ async fn test_plan_components_no_dir() { let mut changed_files = Vec::new(); let env_vars = Arc::new(HashMap::new()); + let options = PlanOptions { + changes_only: false, + interactive: false, + }; + // Should not fail if directory doesn't exist let res = plan_components_or_keys( &client, workspace_dir, - false, - false, + options, "non-existent", env_vars, &mut changed_files, @@ -32,7 +37,11 @@ async fn test_plan_components_no_dir() { async fn test_check_keys_drift_fail() { // Client that will fail to connect let client = KeycloakClient::new("http://localhost:1".to_string()); - let res = check_keys_drift(&client, true, "master").await; + let options = PlanOptions { + changes_only: true, + interactive: false, + }; + let res = check_keys_drift(&client, options, "master").await; // check_keys_drift ignores error if not available assert!(res.is_ok()); } @@ -51,11 +60,15 @@ async fn test_plan_components_with_invalid_yaml() { let mut changed_files = Vec::new(); let env_vars = Arc::new(HashMap::new()); + let options = PlanOptions { + changes_only: false, + interactive: false, + }; + let res = plan_components_or_keys( &client, workspace_dir, - false, - false, + options, "components", env_vars, &mut changed_files,