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 d63455f..0000000 --- a/src/apply.rs +++ /dev/null @@ -1,1186 +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 crate::utils::ui::{ACTION, SUCCESS_CREATE, SUCCESS_UPDATE, WARN}; -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; - -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), - &realm_name, - ) - .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>>, - realm_name: &str, -) -> Result<()> { - apply_realm( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - realm_name, - ) - .await?; - - let mut set = JoinSet::new(); - - // Roles - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_roles( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Identity Providers - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_identity_providers( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Clients - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_clients( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Client Scopes - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_client_scopes( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Groups - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_groups( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Users - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_users( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Authentication Flows - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_authentication_flows( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Required Actions - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_required_actions( - &client, - &workspace_dir, - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Components - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_components_or_keys( - &client, - &workspace_dir, - "components", - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - // Keys - { - let client = client.clone(); - let workspace_dir = workspace_dir.clone(); - let env_vars = Arc::clone(&env_vars); - let planned_files = Arc::clone(&planned_files); - let realm_name = realm_name.to_string(); - set.spawn(async move { - apply_components_or_keys( - &client, - &workspace_dir, - "keys", - env_vars, - planned_files, - &realm_name, - ) - .await - }); - } - - while let Some(res) = set.join_next().await { - res??; - } - - Ok(()) -} - -async fn apply_realm( - client: &KeycloakClient, - workspace_dir: &std::path::Path, - env_vars: Arc>, - planned_files: Arc>>, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to update realm '{}'", realm_name))?; - 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>>, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to get roles for realm '{}'", realm_name))?; - let existing_roles_map: HashMap = existing_roles - .into_iter() - .filter_map(|r| { - let identity = r.get_identity(); - let id = r.id; - 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); - let realm_name = realm_name.to_string(); - 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.with_context(|| { - format!( - "Failed to update role '{}' in realm '{}'", - role_rep.get_name(), - realm_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.with_context(|| { - format!( - "Failed to create role '{}' in realm '{}'", - role_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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.with_context(|| { - format!( - "Failed to get identity providers for realm '{}'", - realm_name - ) - })?; - 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); - let realm_name = realm_name.to_string(); - 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 - .with_context(|| { - format!( - "Failed to update identity provider '{}' in realm '{}'", - idp_rep.get_name(), - realm_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 - .with_context(|| { - format!( - "Failed to create identity provider '{}' in realm '{}'", - idp_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to get clients for realm '{}'", realm_name))?; - 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); - let realm_name = realm_name.to_string(); - 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 - .with_context(|| { - format!( - "Failed to update client '{}' in realm '{}'", - client_rep.get_name(), - realm_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.with_context(|| { - format!( - "Failed to create client '{}' in realm '{}'", - client_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to get client scopes for realm '{}'", realm_name))?; - 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 - .with_context(|| { - format!( - "Failed to update client scope '{}' in realm '{}'", - scope_rep.get_name(), - realm_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 - .with_context(|| { - format!( - "Failed to create client scope '{}' in realm '{}'", - scope_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to get groups for realm '{}'", realm_name))?; - 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.with_context(|| { - format!( - "Failed to update group '{}' in realm '{}'", - group_rep.get_name(), - realm_name - ) - })?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated group {}", group_rep.get_name())).cyan() - ); - } - } else { - group_rep.id = None; - client.create_group(&group_rep).await.with_context(|| { - format!( - "Failed to create group '{}' in realm '{}'", - group_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to get users for realm '{}'", realm_name))?; - 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.with_context(|| { - format!( - "Failed to update user '{}' in realm '{}'", - user_rep.get_name(), - realm_name - ) - })?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated user {}", user_rep.get_name())).cyan() - ); - } - } else { - user_rep.id = None; - client.create_user(&user_rep).await.with_context(|| { - format!( - "Failed to create user '{}' in realm '{}'", - user_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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.with_context(|| { - format!( - "Failed to get authentication flows for realm '{}'", - realm_name - ) - })?; - 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 - .with_context(|| { - format!( - "Failed to update authentication flow '{}' in realm '{}'", - flow_rep.get_name(), - realm_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 - .with_context(|| { - format!( - "Failed to create authentication flow '{}' in realm '{}'", - flow_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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.with_context(|| { - format!("Failed to get required actions for realm '{}'", realm_name) - })?; - 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 - .with_context(|| { - format!( - "Failed to update required action '{}' in realm '{}'", - action_rep.get_name(), - realm_name - ) - })?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated required action {}", action_rep.get_name())).cyan() - ); - } else { - // Register - client - .register_required_action(&action_rep) - .await - .with_context(|| { - format!( - "Failed to register required action '{}' in realm '{}'", - action_rep.get_name(), - realm_name - ) - })?; - client - .update_required_action(&identity, &action_rep) - .await - .with_context(|| { - format!( - "Failed to configure registered required action '{}' in realm '{}'", - action_rep.get_name(), - realm_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>>, - realm_name: &str, -) -> 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 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 - .with_context(|| { - format!( - "Failed to update component '{}' in realm '{}'", - component_rep.get_name(), - realm_name - ) - })?; - println!( - " {} {}", - SUCCESS_UPDATE, - style(format!("Updated component {}", component_rep.get_name())).cyan() - ); - } - } else { - component_rep.id = None; - client - .create_component(&component_rep) - .await - .with_context(|| { - format!( - "Failed to create component '{}' in realm '{}'", - component_rep.get_name(), - realm_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..09ac6be --- /dev/null +++ b/src/apply/actions.rs @@ -0,0 +1,301 @@ +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>>, + realm_name: &str, +) -> 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.with_context(|| { + format!("Failed to get required actions for realm '{}'", realm_name) + })?; + 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); + let realm_name = realm_name.to_string(); + 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 + .with_context(|| { + format!( + "Failed to update required action '{}' in realm '{}'", + action_rep.get_name(), + realm_name + ) + })?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated required action {}", action_rep.get_name())) + .cyan() + ); + } else { + // Register + client + .register_required_action(&action_rep) + .await + .with_context(|| { + format!( + "Failed to register required action '{}' in realm '{}'", + action_rep.get_name(), + realm_name + ) + })?; + client + .update_required_action(&identity, &action_rep) + .await + .with_context(|| { + format!( + "Failed to configure registered required action '{}' in realm '{}'", + action_rep.get_name(), + realm_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), + "test", + ) + .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), + "test", + ) + .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), + "test", + ) + .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), + "test", + ) + .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..7383459 --- /dev/null +++ b/src/apply/clients.rs @@ -0,0 +1,255 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::{SUCCESS_CREATE, SUCCESS_UPDATE}; +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; + +pub async fn apply_clients( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to get clients for realm '{}'", realm_name))?; + 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); + let realm_name = realm_name.to_string(); + 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 + .with_context(|| { + format!( + "Failed to update client '{}' in realm '{}'", + client_rep.get_name(), + realm_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.with_context(|| { + format!( + "Failed to create client '{}' in realm '{}'", + client_rep.get_name(), + realm_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::{ + Json, Router, + http::StatusCode, + routing::{get, 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/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), + "test", + ) + .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), + "test", + ) + .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), + "test", + ) + .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..a9c572d --- /dev/null +++ b/src/apply/components.rs @@ -0,0 +1,275 @@ +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>>, + realm_name: &str, +) -> 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 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); + let realm_name = realm_name.to_string(); + 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 + .with_context(|| { + format!( + "Failed to update component '{}' in realm '{}'", + component_rep.get_name(), + realm_name + ) + })?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated component {}", component_rep.get_name())) + .cyan() + ); + } + } else { + component_rep.id = None; + client + .create_component(&component_rep) + .await + .with_context(|| { + format!( + "Failed to create component '{}' in realm '{}'", + component_rep.get_name(), + realm_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), + "test", + ) + .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), + "test", + ) + .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..d2735d8 --- /dev/null +++ b/src/apply/flows.rs @@ -0,0 +1,247 @@ +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>>, + realm_name: &str, +) -> 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.with_context(|| { + format!( + "Failed to get authentication flows for realm '{}'", + realm_name + ) + })?; + 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); + let realm_name = realm_name.to_string(); + 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 + .with_context(|| { + format!( + "Failed to update authentication flow '{}' in realm '{}'", + flow_rep.get_name(), + realm_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 + .with_context(|| { + format!( + "Failed to create authentication flow '{}' in realm '{}'", + flow_rep.get_name(), + realm_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), + "test", + ) + .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), + "test", + ) + .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..c7dd55b --- /dev/null +++ b/src/apply/groups.rs @@ -0,0 +1,230 @@ +use crate::client::KeycloakClient; +use crate::models::{GroupRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::{SUCCESS_CREATE, SUCCESS_UPDATE}; +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; + +pub async fn apply_groups( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to get groups for realm '{}'", realm_name))?; + 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); + let realm_name = realm_name.to_string(); + 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.with_context(|| { + format!( + "Failed to update group '{}' in realm '{}'", + group_rep.get_name(), + realm_name + ) + })?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated group {}", group_rep.get_name())).cyan() + ); + } + } else { + group_rep.id = None; + client.create_group(&group_rep).await.with_context(|| { + format!( + "Failed to create group '{}' in realm '{}'", + group_rep.get_name(), + realm_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\npath: /existing-group", + ) + .unwrap(); + + let res = apply_groups( + &client, + temp.path(), + Arc::new(HashMap::new()), + Arc::new(None), + "test", + ) + .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), + "test", + ) + .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..83264fd --- /dev/null +++ b/src/apply/idps.rs @@ -0,0 +1,109 @@ +use crate::client::KeycloakClient; +use crate::models::{IdentityProviderRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::{SUCCESS_CREATE, SUCCESS_UPDATE}; +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; + +pub async fn apply_identity_providers( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, + realm_name: &str, +) -> 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.with_context(|| { + format!( + "Failed to get identity providers for realm '{}'", + realm_name + ) + })?; + 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); + let realm_name = realm_name.to_string(); + 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 + .with_context(|| { + format!( + "Failed to update identity provider '{}' in realm '{}'", + idp_rep.get_name(), + realm_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 + .with_context(|| { + format!( + "Failed to create identity provider '{}' in realm '{}'", + idp_rep.get_name(), + realm_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..1cc85e4 --- /dev/null +++ b/src/apply/mod.rs @@ -0,0 +1,345 @@ +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::ui::{ACTION, SUCCESS_CREATE, SUCCESS_UPDATE, WARN}; +use anyhow::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; + +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), + &realm_name, + ) + .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>>, + realm_name: &str, +) -> Result<()> { + realm::apply_realm( + client, + &workspace_dir, + Arc::clone(&env_vars), + Arc::clone(&planned_files), + realm_name, + ) + .await?; + + let mut set = JoinSet::new(); + + // Roles + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + roles::apply_roles( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Identity Providers + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + idps::apply_identity_providers( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Clients + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + clients::apply_clients( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Client Scopes + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + scopes::apply_client_scopes( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Groups + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + groups::apply_groups( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Users + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + users::apply_users( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Authentication Flows + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + flows::apply_authentication_flows( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Required Actions + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + actions::apply_required_actions( + &client, + &workspace_dir, + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Components + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + components::apply_components_or_keys( + &client, + &workspace_dir, + "components", + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + // Keys + { + let client = client.clone(); + let workspace_dir = workspace_dir.clone(); + let env_vars = Arc::clone(&env_vars); + let planned_files = Arc::clone(&planned_files); + let realm_name = realm_name.to_string(); + set.spawn(async move { + components::apply_components_or_keys( + &client, + &workspace_dir, + "keys", + env_vars, + planned_files, + &realm_name, + ) + .await + }); + } + + while let Some(res) = set.join_next().await { + res??; + } + + Ok(()) +} diff --git a/src/apply/realm.rs b/src/apply/realm.rs new file mode 100644 index 0000000..c0cfa1d --- /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 crate::utils::ui::SUCCESS_UPDATE; +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; + +pub async fn apply_realm( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to update realm '{}'", realm_name))?; + 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..834f3a8 --- /dev/null +++ b/src/apply/roles.rs @@ -0,0 +1,233 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, RoleRepresentation}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::{SUCCESS_CREATE, SUCCESS_UPDATE}; +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; + +pub async fn apply_roles( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to get roles for realm '{}'", realm_name))?; + let existing_roles_map: HashMap = existing_roles + .into_iter() + .filter_map(|r| { + let identity = r.get_identity(); + let id = r.id; + 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); + let realm_name = realm_name.to_string(); + 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.with_context(|| { + format!( + "Failed to update role '{}' in realm '{}'", + role_rep.get_name(), + realm_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.with_context(|| { + format!( + "Failed to create role '{}' in realm '{}'", + role_rep.get_name(), + realm_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), + "test", + ) + .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), + "test", + ) + .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..bce9922 --- /dev/null +++ b/src/apply/scopes.rs @@ -0,0 +1,235 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientScopeRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::{SUCCESS_CREATE, SUCCESS_UPDATE}; +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; + +pub async fn apply_client_scopes( + client: &KeycloakClient, + workspace_dir: &std::path::Path, + env_vars: Arc>, + planned_files: Arc>>, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to get client scopes for realm '{}'", realm_name))?; + 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); + let realm_name = realm_name.to_string(); + 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 + .with_context(|| { + format!( + "Failed to update client scope '{}' in realm '{}'", + scope_rep.get_name(), + realm_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 + .with_context(|| { + format!( + "Failed to create client scope '{}' in realm '{}'", + scope_rep.get_name(), + realm_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), + "test", + ) + .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), + "test", + ) + .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..7217b65 --- /dev/null +++ b/src/apply/users.rs @@ -0,0 +1,231 @@ +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>>, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to get users for realm '{}'", realm_name))?; + 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); + let realm_name = realm_name.to_string(); + 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.with_context(|| { + format!( + "Failed to update user '{}' in realm '{}'", + user_rep.get_name(), + realm_name + ) + })?; + println!( + " {} {}", + SUCCESS_UPDATE, + style(format!("Updated user {}", user_rep.get_name())).cyan() + ); + } + } else { + user_rep.id = None; + client.create_user(&user_rep).await.with_context(|| { + format!( + "Failed to create user '{}' in realm '{}'", + user_rep.get_name(), + realm_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), + "test", + ) + .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), + "test", + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to create user") + ); + } +} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 9818a0c..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,1157 +0,0 @@ -use crate::models::{ - ClientRepresentation, ClientScopeRepresentation, ComponentRepresentation, - CredentialRepresentation, GroupRepresentation, IdentityProviderRepresentation, - RoleRepresentation, UserRepresentation, -}; -use crate::utils::ui::{ERROR, INFO, SUCCESS_CREATE, WARN}; -use anyhow::{Context, Result}; -use console::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; - -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_CREATE, - 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_CREATE, - 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_CREATE, - 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_CREATE, - 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_CREATE, - 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) -} - -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_CREATE, - 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_CREATE, - 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_CREATE, - 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() { - 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!(!role.client_role); - - // 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()); - } -} diff --git a/src/cli/client.rs b/src/cli/client.rs new file mode 100644 index 0000000..6d029ac --- /dev/null +++ b/src/cli/client.rs @@ -0,0 +1,178 @@ +use crate::models::{ClientRepresentation, ClientScopeRepresentation}; +use crate::utils::ui::SUCCESS_CREATE; +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_CREATE, + 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_CREATE, + 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..1cc3e50 --- /dev/null +++ b/src/cli/group.rs @@ -0,0 +1,83 @@ +use crate::models::GroupRepresentation; +use crate::utils::ui::SUCCESS_CREATE; +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_CREATE, + 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 realm_dir = workspace_dir.join(realm).join("groups"); + fs::create_dir_all(&realm_dir) + .await + .context("Failed to create groups directory")?; + + let file_path = realm_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()); + + let content = fs::read_to_string(&file_path).await.unwrap(); + let group: GroupRepresentation = serde_yaml::from_str(&content).unwrap(); + assert_eq!(group.name.as_deref(), Some("my-group")); + } +} diff --git a/src/cli/idp.rs b/src/cli/idp.rs new file mode 100644 index 0000000..f75b670 --- /dev/null +++ b/src/cli/idp.rs @@ -0,0 +1,103 @@ +use crate::models::IdentityProviderRepresentation; +use crate::utils::ui::SUCCESS_CREATE; +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("IDP Alias (e.g. google, github)") + .interact_text()?; + + let provider_id: String = Input::with_theme(&theme) + .with_prompt("Provider ID (e.g. oidc, saml, google)") + .default(alias.clone()) + .interact_text()?; + + create_idp_yaml(workspace_dir, &realm, &alias, &provider_id).await?; + + println!( + "{} {}", + SUCCESS_CREATE, + 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 realm_dir = workspace_dir.join(realm).join("identity-providers"); + fs::create_dir_all(&realm_dir) + .await + .context("Failed to create identity-providers directory")?; + + let file_path = realm_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()); + + let content = fs::read_to_string(&file_path).await.unwrap(); + let idp: IdentityProviderRepresentation = serde_yaml::from_str(&content).unwrap(); + assert_eq!(idp.alias.as_deref(), Some("google")); + } +} diff --git a/src/cli/keys.rs b/src/cli/keys.rs new file mode 100644 index 0000000..5a6781b --- /dev/null +++ b/src/cli/keys.rs @@ -0,0 +1,266 @@ +use crate::models::ComponentRepresentation; +use crate::utils::ui::{INFO, SUCCESS_CREATE}; +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_CREATE, + 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..67b70a2 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,122 @@ +pub mod client; +pub mod group; +pub mod idp; +pub mod keys; +pub mod role; +pub mod user; + +use crate::utils::ui::{ERROR, INFO}; +use anyhow::Result; +use console::style; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::path::PathBuf; + +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..cc3894b --- /dev/null +++ b/src/cli/role.rs @@ -0,0 +1,146 @@ +use crate::models::RoleRepresentation; +use crate::utils::ui::SUCCESS_CREATE; +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_CREATE, + 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!(!role.client_role); + + // 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..e4e63da --- /dev/null +++ b/src/cli/user.rs @@ -0,0 +1,394 @@ +use crate::models::{CredentialRepresentation, UserRepresentation}; +use crate::utils::ui::{SUCCESS_CREATE, WARN}; +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_CREATE, + 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_CREATE, + 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 e9d8dfd..3f4f407 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -5,7 +5,7 @@ use crate::models::{ RequiredActionProviderRepresentation, ResourceMeta, RoleRepresentation, UserRepresentation, }; use crate::utils::to_sorted_yaml_with_secrets; -use crate::utils::ui::{CHECK, SEARCH, WARN}; +use crate::utils::ui::{CHECK, SEARCH, SUCCESS, WARN}; use anyhow::{Context, Result}; use console::style; use dialoguer::{Confirm, theme::ColorfulTheme}; @@ -100,36 +100,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, @@ -219,19 +189,22 @@ where while let Some(res) = set.join_next().await { res.context("Task panicked")??; } - println!( - " {} {}", - CHECK, - style(format!( - "Exported {} to {}/", - T::label(), - target_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default() - )) - .green() - ); + { + let _lock = prompt_mutex.lock().await; + println!( + " {} {}", + SUCCESS, + style(format!( + "Exported {} to {}/", + T::label(), + target_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + )) + .green() + ); + } Ok(()) } @@ -253,25 +226,39 @@ 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 realm_path = workspace_dir.join("realm.yaml"); - write_if_changed(&realm_path, &realm_yaml, yes).await?; - println!( - " {} {}", - CHECK, - style("Exported realm configuration to realm.yaml").green() - ); - let mut set = tokio::task::JoinSet::new(); let workspace_dir = Arc::new(workspace_dir); + // Fetch realm configuration in parallel + { + let client = client.clone(); + let realm_name = realm_name.to_string(); + let workspace_dir = Arc::clone(&workspace_dir); + let all_secrets = Arc::clone(&all_secrets); + let prompt_mutex = Arc::clone(&prompt_mutex); + set.spawn(async move { + 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 realm_path = workspace_dir.join("realm.yaml"); + write_if_changed_with_mutex(&realm_path, &realm_yaml, yes, Arc::clone(&prompt_mutex)) + .await?; + { + let _lock = prompt_mutex.lock().await; + println!( + " {} {}", + SUCCESS, + style("Exported realm configuration to realm.yaml").green() + ); + } + Ok::<(), anyhow::Error>(()) + }); + } + // Fetch resources in parallel spawn_inspect::( &mut set, 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 ba452cf..ee4efac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +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::utils::ui::{ACTION, SEARCH}; -use app::validate; +use anyhow::Result; +use app::args::Cli; use clap::Parser; -use console::style; - -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<()> { @@ -33,6 +10,7 @@ async fn main() -> Result<()> { let mut cli = Cli::parse(); + // Load skipped fields from environment if not provided if cli.password.is_none() { cli.password = std::env::var("KEYCLOAK_PASSWORD").ok(); } @@ -40,104 +18,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).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 } => { - 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 37d5b73..68067e2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -152,7 +152,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 @@ -196,7 +196,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() @@ -239,7 +239,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()) @@ -277,7 +277,10 @@ pub struct GroupRepresentation { impl KeycloakResource for GroupRepresentation { fn get_identity(&self) -> Option { - 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()) @@ -344,7 +347,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 @@ -416,7 +419,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()) @@ -504,7 +507,7 @@ pub struct ComponentRepresentation { impl KeycloakResource for ComponentRepresentation { fn get_identity(&self) -> Option { - self.id.clone() + 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.rs b/src/plan.rs deleted file mode 100644 index d627df2..0000000 --- a/src/plan.rs +++ /dev/null @@ -1,1164 +0,0 @@ -use crate::client::KeycloakClient; -use crate::models::{ - AuthenticationFlowRepresentation, ClientRepresentation, ClientScopeRepresentation, - ComponentRepresentation, GroupRepresentation, IdentityProviderRepresentation, KeycloakResource, - RealmRepresentation, RequiredActionProviderRepresentation, RoleRepresentation, - UserRepresentation, -}; - -use crate::utils::ui::{CHECK, MEMO, SEARCH, SPARKLE, WARN}; -use anyhow::{Context, Result}; -use console::{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; - -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{} {}", - SEARCH, - 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, - &realm_name, - ) - .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, - realm_name: &str, -) -> Result<()> { - // 1. Plan Realm - plan_realm( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 2. Plan Roles - plan_roles( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 3. Plan Clients - plan_clients( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 4. Plan Identity Providers - plan_identity_providers( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 5. Plan Client Scopes - plan_client_scopes( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 6. Plan Groups - plan_groups( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 7. Plan Users - plan_users( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 8. Plan Authentication Flows - plan_authentication_flows( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 9. Plan Required Actions - plan_required_actions( - client, - &workspace_dir, - changes_only, - interactive, - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - - // 10. Plan Components - plan_components_or_keys( - client, - &workspace_dir, - changes_only, - interactive, - "components", - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .await?; - plan_components_or_keys( - client, - &workspace_dir, - changes_only, - interactive, - "keys", - Arc::clone(&env_vars), - changed_files, - realm_name, - ) - .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 {}:", MEMO, 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 {}", CHECK, 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, - realm_name: &str, -) -> 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 - .with_context(|| format!("Failed to get client scopes for realm '{}'", realm_name))?; - 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: {}", - SPARKLE, - 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, - realm_name: &str, -) -> Result<()> { - let groups_dir = workspace_dir.join("groups"); - if async_fs::try_exists(&groups_dir).await? { - let existing_groups = client - .get_groups() - .await - .with_context(|| format!("Failed to get groups for realm '{}'", realm_name))?; - 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: {}", - SPARKLE, - 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, - realm_name: &str, -) -> Result<()> { - let users_dir = workspace_dir.join("users"); - if async_fs::try_exists(&users_dir).await? { - let existing_users = client - .get_users() - .await - .with_context(|| format!("Failed to get users for realm '{}'", realm_name))?; - 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: {}", SPARKLE, 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, - realm_name: &str, -) -> 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.with_context(|| { - format!( - "Failed to get authentication flows for realm '{}'", - realm_name - ) - })?; - 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: {}", - SPARKLE, - 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, - realm_name: &str, -) -> 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_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 { - print_diff( - &format!("RequiredAction {}", local_action.get_name()), - Some(remote), - &local_action, - changes_only, - "action", - )? - } else { - println!( - "\n{} Will create RequiredAction: {}", - SPARKLE, - 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(()) -} - -#[allow(clippy::too_many_arguments)] -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, - realm_name: &str, -) -> 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 for realm '{}'", realm_name))?; - 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: {}", - SPARKLE, - 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, - realm_name: &str, -) -> 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).with_context(|| { - format!("Failed to get realm '{}' from Keycloak", realm_name) - }); - } - } - }; - - 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, - realm_name: &str, -) -> Result<()> { - let roles_dir = workspace_dir.join("roles"); - if async_fs::try_exists(&roles_dir).await? { - let existing_roles = client - .get_roles() - .await - .with_context(|| format!("Failed to get roles for realm '{}'", realm_name))?; - 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: {}", SPARKLE, 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, - realm_name: &str, -) -> Result<()> { - let clients_dir = workspace_dir.join("clients"); - if async_fs::try_exists(&clients_dir).await? { - let existing_clients = client - .get_clients() - .await - .with_context(|| format!("Failed to get clients for realm '{}'", realm_name))?; - 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: {}", - SPARKLE, - 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, - realm_name: &str, -) -> 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.with_context(|| { - format!( - "Failed to get identity providers for realm '{}'", - realm_name - ) - })?; - 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: {}", - SPARKLE, - 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.", - WARN, - style(provider_id).yellow() - ); - } - } - } - } - } - - Ok(()) -} diff --git a/src/plan/actions.rs b/src/plan/actions.rs new file mode 100644 index 0000000..963e541 --- /dev/null +++ b/src/plan/actions.rs @@ -0,0 +1,125 @@ +use crate::client::KeycloakClient; +use crate::models::{KeycloakResource, RequiredActionProviderRepresentation}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::SPARKLE; +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_required_actions( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> 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_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(); + let realm_name = realm_name.to_string(); + + 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 + ) + })?; + 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 + ) + })?; + + 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::< + ( + 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: {}", + SPARKLE, + 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..a50a09d --- /dev/null +++ b/src/plan/clients.rs @@ -0,0 +1,112 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::SPARKLE; +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_clients( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> Result<()> { + let clients_dir = workspace_dir.join("clients"); + if async_fs::try_exists(&clients_dir).await? { + let existing_clients = client + .get_clients() + .await + .with_context(|| format!("Failed to get clients for realm '{}'", realm_name))?; + 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(); + let realm_name = realm_name.to_string(); + + 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))?; + 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 {:?} in realm '{}'", path, realm_name))?; + + let identity = local_client + .get_identity() + .with_context(|| format!("Failed to get identity for client in {:?} in realm '{}'", path, realm_name))?; + 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: {}", + SPARKLE, + 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..0b7c6fc --- /dev/null +++ b/src/plan/components.rs @@ -0,0 +1,218 @@ +use crate::client::KeycloakClient; +use crate::models::{ComponentRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::{SPARKLE, WARN}; +use anyhow::{Context, Result}; +use console::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::{PlanOptions, print_diff}; + +pub async fn plan_components_or_keys( + client: &KeycloakClient, + workspace_dir: &Path, + options: PlanOptions, + dir_name: &str, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> 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 for realm '{}'", realm_name))?; + 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(); + let realm_name = realm_name.to_string(); + + 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 + ) + })?; + 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 + ) + })?; + + 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, + options.changes_only, + prefix, + )? + } else { + println!( + "\n{} Will create Component: {}", + SPARKLE, + 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, + options.changes_only, + prefix, + )? + }; + + if changed { + let mut include = true; + if options.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, + options: PlanOptions, + realm_name: &str, +) -> Result<()> { + if !options.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: {}) in realm '{}' is near expiration or expired! Consider rotating keys.", + WARN, + style(provider_id).yellow(), + realm_name + ); + } + } + } + } + } + + Ok(()) +} diff --git a/src/plan/flows.rs b/src/plan/flows.rs new file mode 100644 index 0000000..a62c999 --- /dev/null +++ b/src/plan/flows.rs @@ -0,0 +1,116 @@ +use crate::client::KeycloakClient; +use crate::models::{AuthenticationFlowRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::SPARKLE; +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_authentication_flows( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> 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.with_context(|| { + format!( + "Failed to get authentication flows for realm '{}'", + realm_name + ) + })?; + 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: {}", + SPARKLE, + 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..e488240 --- /dev/null +++ b/src/plan/groups.rs @@ -0,0 +1,111 @@ +use crate::client::KeycloakClient; +use crate::models::{GroupRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::SPARKLE; +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_groups( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> Result<()> { + let groups_dir = workspace_dir.join("groups"); + if async_fs::try_exists(&groups_dir).await? { + let existing_groups = client + .get_groups() + .await + .with_context(|| format!("Failed to get groups for realm '{}'", realm_name))?; + 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: {}", + SPARKLE, + 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..c61bb4e --- /dev/null +++ b/src/plan/idps.rs @@ -0,0 +1,116 @@ +use crate::client::KeycloakClient; +use crate::models::{IdentityProviderRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::SPARKLE; +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_identity_providers( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> 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.with_context(|| { + format!( + "Failed to get identity providers for realm '{}'", + realm_name + ) + })?; + 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: {}", + SPARKLE, + 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..cd36e16 --- /dev/null +++ b/src/plan/mod.rs @@ -0,0 +1,284 @@ +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 crate::utils::ui::{ACTION, CHECK, MEMO, WARN}; + +use anyhow::Result; +use console::{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; + +#[derive(Debug, Clone, Copy)] +pub struct PlanOptions { + pub changes_only: bool, + pub interactive: bool, +} + +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, + &realm_name, + ) + .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, + realm_name: &str, +) -> Result<()> { + realm::plan_realm( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + roles::plan_roles( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + clients::plan_clients( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + idps::plan_identity_providers( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + scopes::plan_client_scopes( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + groups::plan_groups( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + users::plan_users( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + flows::plan_authentication_flows( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + actions::plan_required_actions( + client, + &workspace_dir, + changes_only, + interactive, + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + + let options = PlanOptions { + changes_only, + interactive, + }; + + components::plan_components_or_keys( + client, + &workspace_dir, + options, + "components", + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + components::plan_components_or_keys( + client, + &workspace_dir, + options, + "keys", + Arc::clone(&env_vars), + changed_files, + realm_name, + ) + .await?; + components::check_keys_drift(client, options, realm_name).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 {}:", MEMO, 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 {}", CHECK, name); + } + Ok(changed) +} diff --git a/src/plan/realm.rs b/src/plan/realm.rs new file mode 100644 index 0000000..05ce980 --- /dev/null +++ b/src/plan/realm.rs @@ -0,0 +1,67 @@ +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, + realm_name: &str, +) -> 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).with_context(|| { + format!("Failed to get realm '{}' from Keycloak", realm_name) + }); + } + } + }; + + 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..93ec292 --- /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 crate::utils::ui::SPARKLE; +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_roles( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> Result<()> { + let roles_dir = workspace_dir.join("roles"); + if async_fs::try_exists(&roles_dir).await? { + let existing_roles = client + .get_roles() + .await + .with_context(|| format!("Failed to get roles for realm '{}'", realm_name))?; + 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: {}", SPARKLE, 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..590dad0 --- /dev/null +++ b/src/plan/scopes.rs @@ -0,0 +1,114 @@ +use crate::client::KeycloakClient; +use crate::models::{ClientScopeRepresentation, KeycloakResource}; +use crate::utils::secrets::substitute_secrets; +use crate::utils::ui::SPARKLE; +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_client_scopes( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> 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 + .with_context(|| format!("Failed to get client scopes for realm '{}'", realm_name))?; + 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: {}", + SPARKLE, + 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..75818f5 --- /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 crate::utils::ui::SPARKLE; +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_users( + client: &KeycloakClient, + workspace_dir: &Path, + changes_only: bool, + interactive: bool, + env_vars: Arc>, + changed_files: &mut Vec, + realm_name: &str, +) -> Result<()> { + let users_dir = workspace_dir.join("users"); + if async_fs::try_exists(&users_dir).await? { + let existing_users = client + .get_users() + .await + .with_context(|| format!("Failed to get users for realm '{}'", realm_name))?; + 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: {}", SPARKLE, 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 a6aef47..82b28c7 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -11,25 +11,36 @@ use serde::de::DeserializeOwned; use std::collections::HashSet; use std::path::{Path, PathBuf}; use tokio::fs; +use tokio::task::JoinSet; -async fn read_yaml_files( +async fn read_yaml_files( dir: &Path, file_type: &str, ) -> Result> { let mut results = Vec::new(); 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) - .await - .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) } diff --git a/tests/coverage_test.rs b/tests/coverage_test.rs index 6ddde6f..03801e7 100644 --- a/tests/coverage_test.rs +++ b/tests/coverage_test.rs @@ -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..ed04e87 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 { diff --git a/tests/plan_components_test.rs b/tests/plan_components_test.rs new file mode 100644 index 0000000..68d5cfd --- /dev/null +++ b/tests/plan_components_test.rs @@ -0,0 +1,79 @@ +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; +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()); + + 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, + options, + "non-existent", + env_vars, + &mut changed_files, + "master", + ) + .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 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()); +} + +#[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 options = PlanOptions { + changes_only: false, + interactive: false, + }; + + let res = plan_components_or_keys( + &client, + workspace_dir, + options, + "components", + env_vars, + &mut changed_files, + "master", + ) + .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/validate_test.rs b/tests/validate_test.rs index f97c29a..680d6db 100644 --- a/tests/validate_test.rs +++ b/tests/validate_test.rs @@ -477,6 +477,7 @@ async fn test_validate_empty_auth_flow_alias() { 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), @@ -525,6 +526,7 @@ async fn test_validate_empty_required_action_alias() { 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), @@ -573,6 +575,7 @@ async fn test_validate_empty_required_action_provider_id() { 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), @@ -621,6 +624,7 @@ async fn test_validate_empty_component_name() { 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), @@ -669,6 +673,7 @@ async fn test_validate_missing_component_name() { 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), @@ -715,6 +720,7 @@ async fn test_validate_empty_component_provider_id() { 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),