diff --git a/GEMINI.md b/GEMINI.md index 868087b..00cbe60 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -13,10 +13,14 @@ This document serves as the internal developer guide for `kcd`. It explains the ### Core Modules -- `src/client.rs`: Low-level wrapper for the Keycloak Admin REST API. Handles authentication (Token/Password/Client Credentials) and HTTP requests using `reqwest`. -- `src/models.rs`: Serde-based representations of Keycloak resources. We use `#[serde(flatten)]` with a `HashMap` to maintain forward/backward compatibility with unknown Keycloak fields. -- `src/inspect.rs`: Deep-scans the remote Keycloak server and serializes resources into local files. +- `src/client.rs`: Low-level wrapper for the Keycloak Admin REST API. Handles authentication and provides a **generic CRUD interface** for Keycloak resources. +- `src/models.rs`: Serde-based representations of Keycloak resources. Defines the `KeycloakResource` and `ResourceMeta` traits for generic resource management. +- `src/inspect.rs`: Deep-scans the remote Keycloak server and serializes resources into local files using a **generic, parallelized inspection pipeline**. - `src/utils/secrets.rs`: Uses heuristics to find and mask sensitive fields in configuration objects. +- `src/utils/ui.rs`: Centralized module for CLI output formatting and emoji management. +- `src/clean.rs`: Removes local workspace representations of Keycloak realms and resources using **parallel I/O**. +- `src/validate.rs`: Performs local validation of YAML configurations before they are applied using **async I/O**. +- `src/cli.rs`: Command-line interface definitions and logic for scaffolding new resources. --- @@ -24,13 +28,15 @@ This document serves as the internal developer guide for `kcd`. It explains the To support a new Keycloak resource (e.g., "Event Listeners"): -1. **Update `models.rs`**: Add the `struct` for the resource and register it in the corresponding realm or parent resource. -2. **Update `client.rs`**: Add CRUD methods for the new resource. -3. **Update `inspect.rs`**: Add a function to fetch and save the resource to disk. -4. **Update `plan.rs`**: Logic to compare the local and remote versions of the resource. -5. **Update `apply.rs`**: Hook the resource into the reconciliation loop. -6. **Update `validate.rs`**: (Optional) Add specific validation rules. -7. **Update `cli.rs`**: (Optional) Add interactive scaffolding for the new resource. +1. **Update `models.rs`**: + - Add the `struct` for the resource. + - Implement `KeycloakResource` (for name/ID handling). + - Implement `ResourceMeta` (to define API paths, directory names, and secret prefixes). +2. **Update `inspect.rs`**: Add a `spawn_inspect::(...)` call in the `inspect_realm` function. +3. **Update `plan.rs`**: Logic to compare the local and remote versions of the resource. +4. **Update `apply.rs`**: Hook the resource into the reconciliation loop. +5. **Update `validate.rs`**: (Optional) Add specific validation rules. +6. **Update `cli.rs`**: (Optional) Add interactive scaffolding for the new resource. --- @@ -67,16 +73,19 @@ When detected, the value is replaced by `${KEYCLOAK__ = Emoji("🚀 ", ">> "); -static SUCCESS_CREATE: Emoji<'_, '_> = Emoji("✨ ", "+ "); -static SUCCESS_UPDATE: Emoji<'_, '_> = Emoji("🔄 ", "~ "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); - pub async fn run( client: &KeycloakClient, workspace_dir: PathBuf, @@ -114,6 +110,7 @@ pub async fn run( realm_dir, Arc::clone(&env_vars), Arc::clone(&planned_files), + &realm_name, ) .await?; } @@ -131,87 +128,215 @@ async fn apply_single_realm( 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), - ) - .await?; - apply_roles( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_identity_providers( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_clients( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_client_scopes( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_groups( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_users( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_authentication_flows( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_required_actions( - client, - &workspace_dir, - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_components_or_keys( - client, - &workspace_dir, - "components", - Arc::clone(&env_vars), - Arc::clone(&planned_files), - ) - .await?; - apply_components_or_keys( - client, - &workspace_dir, - "keys", - Arc::clone(&env_vars), - Arc::clone(&planned_files), + 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(()) } @@ -220,6 +345,7 @@ async fn apply_realm( 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"); @@ -237,7 +363,7 @@ async fn apply_realm( client .update_realm(&realm_rep) .await - .context("Failed to update realm")?; + .with_context(|| format!("Failed to update realm '{}'", realm_name))?; println!( " {} {}", SUCCESS_UPDATE, @@ -252,16 +378,20 @@ async fn apply_roles( 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?; + 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.clone(); + let id = r.id; match (identity, id) { (Some(identity), Some(id)) => Some((identity, id)), _ => None, @@ -284,6 +414,7 @@ async fn apply_roles( 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) @@ -297,10 +428,13 @@ async fn apply_roles( if let Some(id) = existing_roles_map.get(&identity) { role_rep.id = Some(id.clone()); // Use remote ID - client - .update_role(id, &role_rep) - .await - .context(format!("Failed to update role {}", role_rep.get_name()))?; + 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, @@ -308,10 +442,13 @@ async fn apply_roles( ); } else { role_rep.id = None; // Don't send ID on create - client - .create_role(&role_rep) - .await - .context(format!("Failed to create role {}", role_rep.get_name()))?; + client.create_role(&role_rep).await.with_context(|| { + format!( + "Failed to create role '{}' in realm '{}'", + role_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -334,11 +471,17 @@ async fn apply_identity_providers( 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?; + 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))) @@ -359,6 +502,7 @@ async fn apply_identity_providers( 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) @@ -376,10 +520,13 @@ async fn apply_identity_providers( client .update_identity_provider(&identity, &idp_rep) .await - .context(format!( - "Failed to update identity provider {}", - idp_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to update identity provider '{}' in realm '{}'", + idp_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_UPDATE, @@ -392,10 +539,13 @@ async fn apply_identity_providers( client .create_identity_provider(&idp_rep) .await - .context(format!( - "Failed to create identity provider {}", - idp_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to create identity provider '{}' in realm '{}'", + idp_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -419,11 +569,15 @@ async fn apply_clients( 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?; + 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))) @@ -444,6 +598,7 @@ async fn apply_clients( 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) @@ -461,10 +616,13 @@ async fn apply_clients( client .update_client(id, &client_rep) .await - .context(format!( - "Failed to update client {}", - client_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to update client '{}' in realm '{}'", + client_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_UPDATE, @@ -473,10 +631,13 @@ async fn apply_clients( } } else { client_rep.id = None; // Don't send ID on create - client.create_client(&client_rep).await.context(format!( - "Failed to create client {}", - client_rep.get_name() - ))?; + client.create_client(&client_rep).await.with_context(|| { + format!( + "Failed to create client '{}' in realm '{}'", + client_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -499,11 +660,15 @@ async fn apply_client_scopes( 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?; + 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))) @@ -535,10 +700,13 @@ async fn apply_client_scopes( client .update_client_scope(id, &scope_rep) .await - .context(format!( - "Failed to update client scope {}", - scope_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to update client scope '{}' in realm '{}'", + scope_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_UPDATE, @@ -550,10 +718,13 @@ async fn apply_client_scopes( client .create_client_scope(&scope_rep) .await - .context(format!( - "Failed to create client scope {}", - scope_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to create client scope '{}' in realm '{}'", + scope_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -571,11 +742,15 @@ async fn apply_groups( 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?; + 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))) @@ -603,10 +778,13 @@ async fn apply_groups( if let Some(existing) = existing_groups_map.get(&identity) { if let Some(id) = &existing.id { group_rep.id = Some(id.clone()); - client - .update_group(id, &group_rep) - .await - .context(format!("Failed to update group {}", group_rep.get_name()))?; + 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, @@ -615,10 +793,13 @@ async fn apply_groups( } } else { group_rep.id = None; - client - .create_group(&group_rep) - .await - .context(format!("Failed to create group {}", group_rep.get_name()))?; + client.create_group(&group_rep).await.with_context(|| { + format!( + "Failed to create group '{}' in realm '{}'", + group_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -636,11 +817,15 @@ async fn apply_users( 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?; + 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))) @@ -668,10 +853,13 @@ async fn apply_users( if let Some(existing) = existing_users_map.get(&identity) { if let Some(id) = &existing.id { user_rep.id = Some(id.clone()); - client - .update_user(id, &user_rep) - .await - .context(format!("Failed to update user {}", user_rep.get_name()))?; + 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, @@ -680,10 +868,13 @@ async fn apply_users( } } else { user_rep.id = None; - client - .create_user(&user_rep) - .await - .context(format!("Failed to create user {}", user_rep.get_name()))?; + client.create_user(&user_rep).await.with_context(|| { + format!( + "Failed to create user '{}' in realm '{}'", + user_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -701,11 +892,17 @@ async fn apply_authentication_flows( 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?; + 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))) @@ -736,10 +933,13 @@ async fn apply_authentication_flows( client .update_authentication_flow(id, &flow_rep) .await - .context(format!( - "Failed to update authentication flow {}", - flow_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to update authentication flow '{}' in realm '{}'", + flow_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_UPDATE, @@ -755,10 +955,13 @@ async fn apply_authentication_flows( client .create_authentication_flow(&flow_rep) .await - .context(format!( - "Failed to create authentication flow {}", - flow_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to create authentication flow '{}' in realm '{}'", + flow_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -780,11 +983,14 @@ async fn apply_required_actions( 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?; + 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() @@ -815,10 +1021,13 @@ async fn apply_required_actions( client .update_required_action(&identity, &action_rep) .await - .context(format!( - "Failed to update required action {}", - action_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to update required action '{}' in realm '{}'", + action_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_UPDATE, @@ -829,17 +1038,23 @@ async fn apply_required_actions( client .register_required_action(&action_rep) .await - .context(format!( - "Failed to register required action {}", - action_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to register required action '{}' in realm '{}'", + action_rep.get_name(), + realm_name + ) + })?; client .update_required_action(&identity, &action_rep) .await - .context(format!( - "Failed to configure registered required action {}", - action_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to configure registered required action '{}' in realm '{}'", + action_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, @@ -862,10 +1077,14 @@ async fn apply_components_or_keys( 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?; + 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, @@ -929,10 +1148,13 @@ async fn apply_components_or_keys( client .update_component(id, &component_rep) .await - .context(format!( - "Failed to update component {}", - component_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to update component '{}' in realm '{}'", + component_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_UPDATE, @@ -944,10 +1166,13 @@ async fn apply_components_or_keys( client .create_component(&component_rep) .await - .context(format!( - "Failed to create component {}", - component_rep.get_name() - ))?; + .with_context(|| { + format!( + "Failed to create component '{}' in realm '{}'", + component_rep.get_name(), + realm_name + ) + })?; println!( " {} {}", SUCCESS_CREATE, diff --git a/src/clean.rs b/src/clean.rs index 491efe6..395ea34 100644 --- a/src/clean.rs +++ b/src/clean.rs @@ -1,14 +1,10 @@ +use crate::utils::ui::{ACTION, ERROR, SUCCESS, WARN}; use anyhow::{Context, Result}; -use console::{Emoji, style}; +use console::style; use dialoguer::{Confirm, theme::ColorfulTheme}; use std::path::PathBuf; use tokio::fs; -static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); -static SUCCESS: Emoji<'_, '_> = Emoji("🎉 ", "* "); -static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); - pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) -> Result<()> { if !workspace_dir.exists() { println!( @@ -62,6 +58,8 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) } } + let mut set = tokio::task::JoinSet::new(); + for target in targets { if target == workspace_dir && realms_to_clean.is_empty() { println!( @@ -72,15 +70,18 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) let mut entries = fs::read_dir(&workspace_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); - if path.is_dir() { - fs::remove_dir_all(&path) - .await - .context(format!("Failed to remove dir {:?}", path))?; - } else { - fs::remove_file(&path) - .await - .context(format!("Failed to remove file {:?}", path))?; - } + let file_type = entry.file_type().await?; + set.spawn(async move { + if file_type.is_dir() { + fs::remove_dir_all(&path) + .await + .context(format!("Failed to remove dir {:?}", path)) + } else { + fs::remove_file(&path) + .await + .context(format!("Failed to remove file {:?}", path)) + } + }); } } else { println!( @@ -88,18 +89,27 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) ACTION, style(format!("Cleaning realm directory {:?}", target)).cyan() ); - if target.is_dir() { - fs::remove_dir_all(&target) - .await - .context(format!("Failed to remove dir {:?}", target))?; - } else { - fs::remove_file(&target) + set.spawn(async move { + let metadata = fs::metadata(&target) .await - .context(format!("Failed to remove file {:?}", target))?; - } + .context(format!("Failed to get metadata for {:?}", target))?; + if metadata.is_dir() { + fs::remove_dir_all(&target) + .await + .context(format!("Failed to remove dir {:?}", target)) + } else { + fs::remove_file(&target) + .await + .context(format!("Failed to remove file {:?}", target)) + } + }); } } + while let Some(res) = set.join_next().await { + res.context("Join error")??; + } + println!( "{} {}", SUCCESS, diff --git a/src/cli.rs b/src/cli.rs index dbb3b7b..9818a0c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,19 +3,15 @@ use crate::models::{ CredentialRepresentation, GroupRepresentation, IdentityProviderRepresentation, RoleRepresentation, UserRepresentation, }; +use crate::utils::ui::{ERROR, INFO, SUCCESS_CREATE, WARN}; use anyhow::{Context, Result}; -use console::{Emoji, style}; +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; -static SUCCESS: Emoji<'_, '_> = Emoji("✨ ", "* "); -static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); -static INFO: Emoji<'_, '_> = Emoji("💡 ", "i "); - pub async fn run(workspace_dir: PathBuf) -> Result<()> { println!( "{} {}", @@ -166,7 +162,7 @@ async fn create_role_interactive(workspace_dir: &Path) -> Result<()> { println!( "{} {}", - SUCCESS, + SUCCESS_CREATE, style(format!( "Successfully generated YAML for role '{}' in realm '{}'.", name, realm @@ -229,7 +225,7 @@ async fn create_group_interactive(workspace_dir: &Path) -> Result<()> { println!( "{} {}", - SUCCESS, + SUCCESS_CREATE, style(format!( "Successfully generated YAML for group '{}' in realm '{}'.", name, realm @@ -283,7 +279,7 @@ async fn create_idp_interactive(workspace_dir: &Path) -> Result<()> { println!( "{} {}", - SUCCESS, + SUCCESS_CREATE, style(format!( "Successfully generated YAML for Identity Provider '{}' in realm '{}'.", alias, realm @@ -352,7 +348,7 @@ async fn create_client_scope_interactive(workspace_dir: &Path) -> Result<()> { println!( "{} {}", - SUCCESS, + SUCCESS_CREATE, style(format!( "Successfully generated YAML for client scope '{}' in realm '{}'.", name, realm @@ -404,7 +400,7 @@ async fn rotate_keys_interactive(workspace_dir: &Path) -> Result<()> { if rotated_count > 0 { println!( "{} {}", - SUCCESS, + SUCCESS_CREATE, style(format!( "Successfully generated {} rotated key component(s) for realm '{}'.", rotated_count, realm @@ -503,6 +499,256 @@ pub async fn rotate_keys_yaml(workspace_dir: &Path, realm: &str) -> Result 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::*; @@ -664,7 +910,7 @@ mod tests { let content = fs::read_to_string(&realm_role_path).await.unwrap(); let role: RoleRepresentation = serde_yaml::from_str(&content).unwrap(); assert_eq!(role.name, "admin"); - assert_eq!(role.client_role, false); + assert!(!role.client_role); // Client role create_role_yaml( @@ -909,253 +1155,3 @@ mod tests { assert!(res.is_err()); } } - -async fn create_client_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let client_id: String = Input::with_theme(&theme) - .with_prompt("Client ID") - .interact_text()?; - - let is_public = Confirm::with_theme(&theme) - .with_prompt("Is this a public client? (No for confidential)") - .default(true) - .interact()?; - - create_client_yaml(workspace_dir, &realm, &client_id, is_public).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for client '{}' in realm '{}'.", - client_id, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_client_yaml( - workspace_dir: &Path, - realm: &str, - client_id: &str, - is_public: bool, -) -> Result<()> { - let client = ClientRepresentation { - id: None, - client_id: Some(client_id.to_string()), - name: None, - description: None, - enabled: Some(true), - protocol: Some("openid-connect".to_string()), - redirect_uris: Some(vec!["/*".to_string()]), - web_origins: Some(vec!["+".to_string()]), - public_client: Some(is_public), - bearer_only: Some(false), - service_accounts_enabled: Some(!is_public), - extra: HashMap::new(), - }; - - let realm_dir = workspace_dir.join(realm).join("clients"); - fs::create_dir_all(&realm_dir) - .await - .context("Failed to create clients directory")?; - - let file_path = realm_dir.join(format!("{}.yaml", client_id)); - let yaml = serde_yaml::to_string(&client).context("Failed to serialize client to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write client YAML file")?; - - Ok(()) -} - -async fn change_user_password_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let username: String = Input::with_theme(&theme) - .with_prompt("Username") - .interact_text()?; - - let new_password = Password::with_theme(&theme) - .with_prompt("New Password") - .with_confirmation("Confirm Password", "Passwords mismatching") - .interact()?; - - change_user_password_yaml(workspace_dir, &realm, &username, &new_password).await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully updated YAML for user '{}' in realm '{}' with new password.", - username, realm - )) - .green() - ); - Ok(()) -} - -pub async fn change_user_password_yaml( - workspace_dir: &Path, - realm: &str, - username: &str, - new_password: &str, -) -> Result<()> { - let file_path = workspace_dir - .join(realm) - .join("users") - .join(format!("{}.yaml", username)); - - if !file_path.exists() { - println!( - "{} {}", - WARN, - style(format!( - "Warning: User file {:?} does not exist. Creating a new one.", - file_path - )) - .yellow() - ); - create_user_yaml(workspace_dir, realm, username, None, None, None).await?; - } - - let yaml_content = fs::read_to_string(&file_path) - .await - .context("Failed to read user YAML file")?; - let mut user: UserRepresentation = - serde_yaml::from_str(&yaml_content).context("Failed to parse user YAML file")?; - - let new_cred = CredentialRepresentation { - id: None, - type_: Some("password".to_string()), - value: Some(new_password.to_string()), - temporary: Some(false), - extra: HashMap::new(), - }; - - if let Some(credentials) = &mut user.credentials { - if let Some(existing) = credentials - .iter_mut() - .find(|c| c.type_.as_deref() == Some("password")) - { - existing.value = Some(new_password.to_string()); - } else { - credentials.push(new_cred); - } - } else { - user.credentials = Some(vec![new_cred]); - } - - let yaml = serde_yaml::to_string(&user).context("Failed to serialize user to YAML")?; - fs::write(&file_path, yaml) - .await - .context("Failed to write updated user YAML file")?; - - Ok(()) -} - -async fn create_user_interactive(workspace_dir: &Path) -> Result<()> { - let theme = ColorfulTheme::default(); - - let realm: String = Input::with_theme(&theme) - .with_prompt("Target Realm") - .interact_text()?; - - let username: String = Input::with_theme(&theme) - .with_prompt("Username") - .interact_text()?; - - let email: String = Input::with_theme(&theme) - .with_prompt("Email") - .allow_empty(true) - .interact_text()?; - - let first_name: String = Input::with_theme(&theme) - .with_prompt("First Name") - .allow_empty(true) - .interact_text()?; - - let last_name: String = Input::with_theme(&theme) - .with_prompt("Last Name") - .allow_empty(true) - .interact_text()?; - - let email_opt = if email.is_empty() { None } else { Some(email) }; - let first_name_opt = if first_name.is_empty() { - None - } else { - Some(first_name) - }; - let last_name_opt = if last_name.is_empty() { - None - } else { - Some(last_name) - }; - - create_user_yaml( - workspace_dir, - &realm, - &username, - email_opt, - first_name_opt, - last_name_opt, - ) - .await?; - - println!( - "{} {}", - SUCCESS, - style(format!( - "Successfully generated YAML for user '{}' in realm '{}'.", - username, realm - )) - .green() - ); - Ok(()) -} - -pub async fn create_user_yaml( - workspace_dir: &Path, - realm: &str, - username: &str, - email: Option, - first_name: Option, - last_name: Option, -) -> Result<()> { - let user = UserRepresentation { - id: None, - username: Some(username.to_string()), - enabled: Some(true), - first_name, - last_name, - email, - email_verified: Some(false), - credentials: None, - extra: HashMap::new(), - }; - - let realm_dir = workspace_dir.join(realm).join("users"); - fs::create_dir_all(&realm_dir) - .await - .context("Failed to create users directory")?; - - let file_path = realm_dir.join(format!("{}.yaml", username)); - let yaml = serde_yaml::to_string(&user).context("Failed to serialize user to YAML")?; - - fs::write(&file_path, yaml) - .await - .context("Failed to write user YAML file")?; - - Ok(()) -} diff --git a/src/client.rs b/src/client.rs index 9435af9..77bcb29 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,6 @@ use crate::models::{ AuthenticationFlowRepresentation, ClientRepresentation, ClientScopeRepresentation, - ComponentRepresentation, GroupRepresentation, IdentityProviderRepresentation, + ComponentRepresentation, GroupRepresentation, IdentityProviderRepresentation, KeycloakResource, RealmRepresentation, RequiredActionProviderRepresentation, RoleRepresentation, UserRepresentation, }; @@ -38,98 +38,109 @@ impl KeycloakClient { self.target_realm = target_realm; } + fn realm_admin_url(&self) -> String { + format!("{}/admin/realms/{}", self.base_url, self.target_realm) + } + + fn resource_url(&self) -> String { + if T::api_path() == "realms" { + format!("{}/admin/realms", self.base_url) + } else { + format!("{}/{}", self.realm_admin_url(), T::api_path()) + } + } + + fn object_url(&self, id: &str) -> String { + if T::api_path() == "realms" { + format!("{}/admin/realms/{}", self.base_url, id) + } else { + format!("{}/{}", self.realm_admin_url(), T::object_path(id)) + } + } + + pub async fn get_resources Deserialize<'a>>( + &self, + ) -> Result> { + self.get(&self.resource_url::()).await + } + + pub async fn get_resource Deserialize<'a>>( + &self, + id: &str, + ) -> Result { + self.get(&self.object_url::(id)).await + } + + pub async fn create_resource(&self, res: &T) -> Result<()> { + self.post(&self.resource_url::(), res).await + } + + pub async fn update_resource( + &self, + id: &str, + res: &T, + ) -> Result<()> { + self.put(&self.object_url::(id), res).await + } + + pub async fn delete_resource(&self, id: &str) -> Result<()> { + self.delete(&self.object_url::(id)).await + } + pub async fn get_realms(&self) -> Result> { - let url = format!("{}/admin/realms", self.base_url); - self.get(&url).await + self.get_resources().await } pub async fn get_realm(&self) -> Result { - let url = format!("{}/admin/realms/{}", self.base_url, self.target_realm); - self.get(&url).await + self.get_resource(&self.target_realm.clone()).await } pub async fn get_clients(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/clients", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn get_roles(&self) -> Result> { - let url = format!("{}/admin/realms/{}/roles", self.base_url, self.target_realm); - self.get(&url).await + self.get_resources().await } pub async fn get_identity_providers(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/identity-provider/instances", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn update_realm(&self, realm_rep: &RealmRepresentation) -> Result<()> { - let url = format!("{}/admin/realms/{}", self.base_url, self.target_realm); - self.put(&url, realm_rep).await + self.update_resource(&self.target_realm.clone(), realm_rep) + .await } pub async fn create_client(&self, client_rep: &ClientRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/clients", - self.base_url, self.target_realm - ); - self.post(&url, client_rep).await + self.create_resource(client_rep).await } pub async fn update_client(&self, id: &str, client_rep: &ClientRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/clients/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, client_rep).await + self.update_resource(id, client_rep).await } pub async fn delete_client(&self, id: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/clients/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id).await } pub async fn create_role(&self, role_rep: &RoleRepresentation) -> Result<()> { - let url = format!("{}/admin/realms/{}/roles", self.base_url, self.target_realm); - self.post(&url, role_rep).await + self.create_resource(role_rep).await } pub async fn update_role(&self, id: &str, role_rep: &RoleRepresentation) -> Result<()> { - // Keycloak API for updating role by ID: PUT /admin/realms/{realm}/roles-by-id/{role-id} - let url = format!( - "{}/admin/realms/{}/roles-by-id/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, role_rep).await + self.update_resource(id, role_rep).await } pub async fn delete_role(&self, id: &str) -> Result<()> { - // Keycloak API for deleting role by ID: DELETE /admin/realms/{realm}/roles-by-id/{role-id} - let url = format!( - "{}/admin/realms/{}/roles-by-id/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id).await } pub async fn create_identity_provider( &self, idp_rep: &IdentityProviderRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/identity-provider/instances", - self.base_url, self.target_realm - ); - self.post(&url, idp_rep).await + self.create_resource(idp_rep).await } pub async fn update_identity_provider( @@ -137,35 +148,20 @@ impl KeycloakClient { alias: &str, idp_rep: &IdentityProviderRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/identity-provider/instances/{}", - self.base_url, self.target_realm, alias - ); - self.put(&url, idp_rep).await + self.update_resource(alias, idp_rep).await } pub async fn delete_identity_provider(&self, alias: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/identity-provider/instances/{}", - self.base_url, self.target_realm, alias - ); - self.delete(&url).await + self.delete_resource::(alias) + .await } pub async fn get_client_scopes(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/client-scopes", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn create_client_scope(&self, scope_rep: &ClientScopeRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/client-scopes", - self.base_url, self.target_realm - ); - self.post(&url, scope_rep).await + self.create_resource(scope_rep).await } pub async fn update_client_scope( @@ -173,96 +169,54 @@ impl KeycloakClient { id: &str, scope_rep: &ClientScopeRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/client-scopes/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, scope_rep).await + self.update_resource(id, scope_rep).await } pub async fn delete_client_scope(&self, id: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/client-scopes/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id).await } pub async fn get_groups(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/groups", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn create_group(&self, group_rep: &GroupRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/groups", - self.base_url, self.target_realm - ); - self.post(&url, group_rep).await + self.create_resource(group_rep).await } pub async fn update_group(&self, id: &str, group_rep: &GroupRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/groups/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, group_rep).await + self.update_resource(id, group_rep).await } pub async fn delete_group(&self, id: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/groups/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id).await } pub async fn get_users(&self) -> Result> { - let url = format!("{}/admin/realms/{}/users", self.base_url, self.target_realm); - self.get(&url).await + self.get_resources().await } pub async fn create_user(&self, user_rep: &UserRepresentation) -> Result<()> { - let url = format!("{}/admin/realms/{}/users", self.base_url, self.target_realm); - self.post(&url, user_rep).await + self.create_resource(user_rep).await } pub async fn update_user(&self, id: &str, user_rep: &UserRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/users/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, user_rep).await + self.update_resource(id, user_rep).await } pub async fn delete_user(&self, id: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/users/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id).await } pub async fn get_authentication_flows(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/authentication/flows", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn create_authentication_flow( &self, flow_rep: &AuthenticationFlowRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/authentication/flows", - self.base_url, self.target_realm - ); - self.post(&url, flow_rep).await + self.create_resource(flow_rep).await } pub async fn update_authentication_flow( @@ -270,27 +224,16 @@ impl KeycloakClient { id: &str, flow_rep: &AuthenticationFlowRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/authentication/flows/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, flow_rep).await + self.update_resource(id, flow_rep).await } pub async fn delete_authentication_flow(&self, id: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/authentication/flows/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id) + .await } pub async fn get_required_actions(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/authentication/required-actions", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn update_required_action( @@ -298,11 +241,7 @@ impl KeycloakClient { alias: &str, action_rep: &RequiredActionProviderRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/authentication/required-actions/{}", - self.base_url, self.target_realm, alias - ); - self.put(&url, action_rep).await + self.update_resource(alias, action_rep).await } pub async fn register_required_action( @@ -335,27 +274,16 @@ impl KeycloakClient { } pub async fn delete_required_action(&self, alias: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/authentication/required-actions/{}", - self.base_url, self.target_realm, alias - ); - self.delete(&url).await + self.delete_resource::(alias) + .await } pub async fn get_components(&self) -> Result> { - let url = format!( - "{}/admin/realms/{}/components", - self.base_url, self.target_realm - ); - self.get(&url).await + self.get_resources().await } pub async fn create_component(&self, component_rep: &ComponentRepresentation) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/components", - self.base_url, self.target_realm - ); - self.post(&url, component_rep).await + self.create_resource(component_rep).await } pub async fn update_component( @@ -363,19 +291,11 @@ impl KeycloakClient { id: &str, component_rep: &ComponentRepresentation, ) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/components/{}", - self.base_url, self.target_realm, id - ); - self.put(&url, component_rep).await + self.update_resource(id, component_rep).await } pub async fn delete_component(&self, id: &str) -> Result<()> { - let url = format!( - "{}/admin/realms/{}/components/{}", - self.base_url, self.target_realm, id - ); - self.delete(&url).await + self.delete_resource::(id).await } async fn get Deserialize<'a>>(&self, url: &str) -> Result { @@ -529,6 +449,13 @@ fn redact_url(url_str: &str) -> String { } } +impl KeycloakClient { + pub async fn get_keys(&self) -> Result { + let url = format!("{}/admin/realms/{}/keys", self.base_url, self.target_realm); + self.get(&url).await + } +} + #[cfg(test)] mod tests { use super::*; @@ -578,10 +505,3 @@ mod tests { ); } } - -impl KeycloakClient { - pub async fn get_keys(&self) -> Result { - let url = format!("{}/admin/realms/{}/keys", self.base_url, self.target_realm); - self.get(&url).await - } -} diff --git a/src/inspect.rs b/src/inspect.rs index 5cb1603..e9d8dfd 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -1,8 +1,13 @@ use crate::client::KeycloakClient; -use crate::models::KeycloakResource; +use crate::models::{ + AuthenticationFlowRepresentation, ClientRepresentation, ClientScopeRepresentation, + ComponentRepresentation, GroupRepresentation, IdentityProviderRepresentation, KeycloakResource, + RequiredActionProviderRepresentation, ResourceMeta, RoleRepresentation, UserRepresentation, +}; use crate::utils::to_sorted_yaml_with_secrets; +use crate::utils::ui::{CHECK, SEARCH, WARN}; use anyhow::{Context, Result}; -use console::{Emoji, style}; +use console::style; use dialoguer::{Confirm, theme::ColorfulTheme}; use sanitize_filename::sanitize; use std::collections::HashMap; @@ -11,10 +16,6 @@ use std::sync::Arc; use tokio::fs; use tokio::sync::Mutex; -static ACTION: Emoji<'_, '_> = Emoji("🔍 ", "> "); -static SUCCESS: Emoji<'_, '_> = Emoji("✅ ", "√ "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); - pub async fn run( client: &KeycloakClient, workspace_dir: PathBuf, @@ -49,7 +50,7 @@ pub async fn run( let realm_dir = workspace_dir.join(&realm_name); println!( "\n{} {}", - ACTION, + SEARCH, style(format!("Inspecting realm: {}", realm_name)) .cyan() .bold() @@ -91,7 +92,7 @@ pub async fn run( .await?; println!( "{} {}", - SUCCESS, + CHECK, style("Exported secrets to .secrets").green() ); } @@ -166,105 +167,51 @@ async fn write_if_changed_with_mutex( Ok(()) } -async fn inspect_realm( +async fn inspect_resources( client: &KeycloakClient, realm_name: &str, - workspace_dir: PathBuf, + target_dir: Arc, all_secrets: Arc>>, yes: bool, prompt_mutex: Arc>, -) -> Result<()> { - if !fs::try_exists(&workspace_dir) +) -> Result<()> +where + T: KeycloakResource + + ResourceMeta + + serde::Serialize + + for<'de> serde::Deserialize<'de> + + Send + + Sync + + 'static, +{ + let resources = client + .get_resources::() .await - .context("Failed to check output directory")? - { - fs::create_dir_all(&workspace_dir) - .await - .context("Failed to create output directory")?; - } + .with_context(|| format!("Failed to fetch {} for realm '{}'", T::label(), realm_name))?; - // 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!( - " {} {}", - SUCCESS, - style("Exported realm configuration to realm.yaml").green() - ); - - // Fetch clients - let clients = client - .get_clients() + if !fs::try_exists(&*target_dir) .await - .context("Failed to fetch clients")?; - let clients_dir = workspace_dir.join("clients"); - if !fs::try_exists(&clients_dir) - .await - .context("Failed to check clients directory")? + .context(format!("Failed to check {} directory", T::label()))? { - fs::create_dir_all(&clients_dir) + fs::create_dir_all(&*target_dir) .await - .context("Failed to create clients directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for client_rep in clients { - let clients_dir = clients_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = client_rep.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = clients_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_client", realm_name); - let yaml = to_sorted_yaml_with_secrets(&client_rep, &prefix, &mut local_secrets) - .context("Failed to serialize client")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); + .context(format!("Failed to create {} directory", T::label()))?; } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported clients to clients/").green() - ); - // Fetch roles - let roles = client.get_roles().await.context("Failed to fetch roles")?; - let roles_dir = workspace_dir.join("roles"); - if !fs::try_exists(&roles_dir) - .await - .context("Failed to check roles directory")? - { - fs::create_dir_all(&roles_dir) - .await - .context("Failed to create roles directory")?; - } let mut set = tokio::task::JoinSet::new(); - for role in roles { - let roles_dir = roles_dir.clone(); + for res in resources { + let target_dir = Arc::clone(&target_dir); let all_secrets = Arc::clone(&all_secrets); let realm_name = realm_name.to_string(); let prompt_mutex = Arc::clone(&prompt_mutex); set.spawn(async move { - let name = role.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = roles_dir.join(filename); + let filename = format!("{}.yaml", sanitize(res.get_filename())); + let path = target_dir.join(filename); let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_role", realm_name); - let yaml = to_sorted_yaml_with_secrets(&role, &prefix, &mut local_secrets) - .context("Failed to serialize role")?; + let prefix = format!("realm_{}_{}", realm_name, T::secret_prefix()); + let yaml = to_sorted_yaml_with_secrets(&res, &prefix, &mut local_secrets).context( + format!("Failed to serialize {} {}", T::label(), res.get_name()), + )?; all_secrets.lock().await.extend(local_secrets); write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await }); @@ -274,317 +221,179 @@ async fn inspect_realm( } println!( " {} {}", - SUCCESS, - style("Exported roles to roles/").green() + CHECK, + style(format!( + "Exported {} to {}/", + T::label(), + target_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + )) + .green() ); - // Fetch client scopes - let client_scopes = client - .get_client_scopes() - .await - .context("Failed to fetch client scopes")?; - let scopes_dir = workspace_dir.join("client-scopes"); - if !fs::try_exists(&scopes_dir) - .await - .context("Failed to check client-scopes directory")? - { - fs::create_dir_all(&scopes_dir) - .await - .context("Failed to create client-scopes directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for scope in client_scopes { - let scopes_dir = scopes_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = scope.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = scopes_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_client_scope", realm_name); - let yaml = to_sorted_yaml_with_secrets(&scope, &prefix, &mut local_secrets) - .context("Failed to serialize client scope")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported client scopes to client-scopes/").green() - ); + Ok(()) +} - // Fetch identity providers - let idps = client - .get_identity_providers() - .await - .context("Failed to fetch identity providers")?; - let idps_dir = workspace_dir.join("identity-providers"); - if !fs::try_exists(&idps_dir) +async fn inspect_realm( + client: &KeycloakClient, + realm_name: &str, + workspace_dir: PathBuf, + all_secrets: Arc>>, + yes: bool, + prompt_mutex: Arc>, +) -> Result<()> { + if !fs::try_exists(&workspace_dir) .await - .context("Failed to check identity-providers directory")? + .context("Failed to check output directory")? { - fs::create_dir_all(&idps_dir) + fs::create_dir_all(&workspace_dir) .await - .context("Failed to create identity-providers directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for idp in idps { - let idps_dir = idps_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = idp.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = idps_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_idp", realm_name); - let yaml = to_sorted_yaml_with_secrets(&idp, &prefix, &mut local_secrets) - .context("Failed to serialize identity provider")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; + .context("Failed to create output directory")?; } - println!( - " {} {}", - SUCCESS, - style("Exported identity providers to identity-providers/").green() - ); - // Fetch groups - let groups = client - .get_groups() - .await - .context("Failed to fetch groups")?; - let groups_dir = workspace_dir.join("groups"); - if !fs::try_exists(&groups_dir) - .await - .context("Failed to check groups directory")? - { - fs::create_dir_all(&groups_dir) - .await - .context("Failed to create groups directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for group in groups { - let groups_dir = groups_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = group.get_name(); - let id = group.id.as_deref().unwrap_or("unknown"); - let filename = format!("{}-{}.yaml", sanitize(&name), id); - let path = groups_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_group", realm_name); - let yaml = to_sorted_yaml_with_secrets(&group, &prefix, &mut local_secrets) - .context("Failed to serialize group")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported groups to groups/").green() - ); + // 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); - // Fetch users - let users = client.get_users().await.context("Failed to fetch users")?; - let users_dir = workspace_dir.join("users"); - if !fs::try_exists(&users_dir) - .await - .context("Failed to check users directory")? - { - fs::create_dir_all(&users_dir) - .await - .context("Failed to create users directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for user in users { - let users_dir = users_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = user.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = users_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_user", realm_name); - let yaml = to_sorted_yaml_with_secrets(&user, &prefix, &mut local_secrets) - .context("Failed to serialize user")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } + let realm_path = workspace_dir.join("realm.yaml"); + write_if_changed(&realm_path, &realm_yaml, yes).await?; println!( " {} {}", - SUCCESS, - style("Exported users to users/").green() + CHECK, + style("Exported realm configuration to realm.yaml").green() ); - // Fetch authentication flows - let flows = client - .get_authentication_flows() - .await - .context("Failed to fetch authentication flows")?; - let flows_dir = workspace_dir.join("authentication-flows"); - if !fs::try_exists(&flows_dir) - .await - .context("Failed to check authentication-flows directory")? - { - fs::create_dir_all(&flows_dir) - .await - .context("Failed to create authentication-flows directory")?; - } let mut set = tokio::task::JoinSet::new(); - for flow in flows { - let flows_dir = flows_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = flow.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = flows_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_flow", realm_name); - let yaml = to_sorted_yaml_with_secrets(&flow, &prefix, &mut local_secrets) - .context("Failed to serialize authentication flow")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported authentication flows to authentication-flows/").green() + let workspace_dir = Arc::new(workspace_dir); + + // Fetch resources in parallel + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, + ); + spawn_inspect::( + &mut set, + client, + realm_name, + &workspace_dir, + &all_secrets, + yes, + &prompt_mutex, ); - // Fetch required actions - let actions = client - .get_required_actions() - .await - .context("Failed to fetch required actions")?; - let actions_dir = workspace_dir.join("required-actions"); - if !fs::try_exists(&actions_dir) - .await - .context("Failed to check required-actions directory")? - { - fs::create_dir_all(&actions_dir) - .await - .context("Failed to create required-actions directory")?; - } - let mut set = tokio::task::JoinSet::new(); - for action in actions { - let actions_dir = actions_dir.clone(); - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = action.get_name(); - let filename = format!("{}.yaml", sanitize(&name)); - let path = actions_dir.join(filename); - let mut local_secrets = HashMap::new(); - let prefix = format!("realm_{}_action", realm_name); - let yaml = to_sorted_yaml_with_secrets(&action, &prefix, &mut local_secrets) - .context("Failed to serialize required action")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } while let Some(res) = set.join_next().await { res.context("Task panicked")??; } - println!( - " {} {}", - SUCCESS, - style("Exported required actions to required-actions/").green() - ); - // Fetch components and keys - let all_components = client - .get_components() - .await - .context("Failed to fetch components")?; + Ok(()) +} - let components_dir = workspace_dir.join("components"); - if !fs::try_exists(&components_dir) - .await - .context("Failed to check components directory")? - { - fs::create_dir_all(&components_dir) - .await - .context("Failed to create components directory")?; - } +fn spawn_inspect( + set: &mut tokio::task::JoinSet>, + client: &KeycloakClient, + realm_name: &str, + workspace_dir: &Arc, + all_secrets: &Arc>>, + yes: bool, + prompt_mutex: &Arc>, +) where + T: KeycloakResource + + ResourceMeta + + serde::Serialize + + for<'de> serde::Deserialize<'de> + + Send + + Sync + + 'static, +{ + let client = client.clone(); + let realm_name = realm_name.to_string(); + let target_dir = Arc::new(workspace_dir.join(T::dir_name())); + let all_secrets = Arc::clone(all_secrets); + let prompt_mutex = Arc::clone(prompt_mutex); - let keys_dir = workspace_dir.join("keys"); - if !fs::try_exists(&keys_dir) + set.spawn(async move { + inspect_resources::( + &client, + &realm_name, + target_dir, + all_secrets, + yes, + prompt_mutex, + ) .await - .context("Failed to check keys directory")? - { - fs::create_dir_all(&keys_dir) - .await - .context("Failed to create keys directory")?; - } - - let mut set = tokio::task::JoinSet::new(); - for component in all_components { - let is_key = component - .provider_type - .as_deref() - .is_some_and(|pt| pt == "org.keycloak.keys.KeyProvider"); - let target_dir = if is_key { - keys_dir.clone() - } else { - components_dir.clone() - }; - - let all_secrets = Arc::clone(&all_secrets); - let realm_name = realm_name.to_string(); - let prompt_mutex = Arc::clone(&prompt_mutex); - set.spawn(async move { - let name = component.get_name(); - let id = component.id.as_deref().unwrap_or("unknown"); - let filename = format!("{}-{}.yaml", sanitize(&name), id); - let path = target_dir.join(filename); - let mut local_secrets = HashMap::new(); - let sub_prefix = if is_key { "key" } else { "component" }; - let prefix = format!("realm_{}_{}", realm_name, sub_prefix); - let yaml = to_sorted_yaml_with_secrets(&component, &prefix, &mut local_secrets) - .context("Failed to serialize component")?; - all_secrets.lock().await.extend(local_secrets); - write_if_changed_with_mutex(&path, &yaml, yes, prompt_mutex).await - }); - } - while let Some(res) = set.join_next().await { - res.context("Task panicked")??; - } - println!( - " {} {}", - SUCCESS, - style("Exported components to components/ and keys to keys/").green() - ); - - Ok(()) + }); } diff --git a/src/main.rs b/src/main.rs index 9e3207e..ba452cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,10 @@ 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 clap::Parser; -use console::{Emoji, style}; - -static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); -static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", "> "); +use console::style; async fn init_client(cli: &Cli) -> Result { let mut client = KeycloakClient::new(cli.server.clone()); @@ -68,7 +66,7 @@ async fn main() -> Result<()> { .cyan() .bold() ); - validate::run(workspace.clone(), &cli.realms)?; + validate::run(workspace.clone(), &cli.realms).await?; } Commands::Apply { workspace, yes } => { let client = init_client(&cli).await?; diff --git a/src/models.rs b/src/models.rs index 2ff2e7f..37d5b73 100644 --- a/src/models.rs +++ b/src/models.rs @@ -5,6 +5,19 @@ use std::collections::HashMap; pub trait KeycloakResource { fn get_identity(&self) -> Option; fn get_name(&self) -> String; + fn api_path() -> &'static str; + fn dir_name() -> &'static str; + fn object_path(id: &str) -> String { + format!("{}/{}", Self::api_path(), id) + } + fn get_filename(&self) -> String { + self.get_name() + } +} + +pub trait ResourceMeta { + fn label() -> &'static str; + fn secret_prefix() -> &'static str; } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -25,6 +38,12 @@ impl KeycloakResource for RealmRepresentation { fn get_name(&self) -> String { self.realm.clone() } + fn api_path() -> &'static str { + "realms" + } + fn dir_name() -> &'static str { + "realms" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -83,6 +102,21 @@ impl KeycloakResource for IdentityProviderRepresentation { fn get_name(&self) -> String { self.alias.clone().unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "identity-provider/instances" + } + fn dir_name() -> &'static str { + "identity-providers" + } +} + +impl ResourceMeta for IdentityProviderRepresentation { + fn label() -> &'static str { + "identity providers" + } + fn secret_prefix() -> &'static str { + "idp" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -126,6 +160,21 @@ impl KeycloakResource for ClientRepresentation { .or_else(|| self.name.clone()) .unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "clients" + } + fn dir_name() -> &'static str { + "clients" + } +} + +impl ResourceMeta for ClientRepresentation { + fn label() -> &'static str { + "clients" + } + fn secret_prefix() -> &'static str { + "client" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -152,6 +201,24 @@ impl KeycloakResource for RoleRepresentation { fn get_name(&self) -> String { self.name.clone() } + fn api_path() -> &'static str { + "roles" + } + fn dir_name() -> &'static str { + "roles" + } + fn object_path(id: &str) -> String { + format!("roles-by-id/{}", id) + } +} + +impl ResourceMeta for RoleRepresentation { + fn label() -> &'static str { + "roles" + } + fn secret_prefix() -> &'static str { + "role" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -177,6 +244,21 @@ impl KeycloakResource for ClientScopeRepresentation { fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "client-scopes" + } + fn dir_name() -> &'static str { + "client-scopes" + } +} + +impl ResourceMeta for ClientScopeRepresentation { + fn label() -> &'static str { + "client scopes" + } + fn secret_prefix() -> &'static str { + "client_scope" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -200,6 +282,28 @@ impl KeycloakResource for GroupRepresentation { fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "groups" + } + fn dir_name() -> &'static str { + "groups" + } + fn get_filename(&self) -> String { + format!( + "{}-{}", + self.get_name(), + self.id.as_deref().unwrap_or("unknown") + ) + } +} + +impl ResourceMeta for GroupRepresentation { + fn label() -> &'static str { + "groups" + } + fn secret_prefix() -> &'static str { + "group" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -247,6 +351,21 @@ impl KeycloakResource for UserRepresentation { .clone() .unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "users" + } + fn dir_name() -> &'static str { + "users" + } +} + +impl ResourceMeta for UserRepresentation { + fn label() -> &'static str { + "users" + } + fn secret_prefix() -> &'static str { + "user" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -302,6 +421,21 @@ impl KeycloakResource for AuthenticationFlowRepresentation { fn get_name(&self) -> String { self.alias.clone().unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "authentication/flows" + } + fn dir_name() -> &'static str { + "authentication-flows" + } +} + +impl ResourceMeta for AuthenticationFlowRepresentation { + fn label() -> &'static str { + "authentication flows" + } + fn secret_prefix() -> &'static str { + "flow" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -331,6 +465,21 @@ impl KeycloakResource for RequiredActionProviderRepresentation { fn get_name(&self) -> String { self.alias.clone().unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "authentication/required-actions" + } + fn dir_name() -> &'static str { + "required-actions" + } +} + +impl ResourceMeta for RequiredActionProviderRepresentation { + fn label() -> &'static str { + "required actions" + } + fn secret_prefix() -> &'static str { + "action" + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -360,6 +509,28 @@ impl KeycloakResource for ComponentRepresentation { fn get_name(&self) -> String { self.name.clone().unwrap_or_else(|| "unknown".to_string()) } + fn api_path() -> &'static str { + "components" + } + fn dir_name() -> &'static str { + "components" + } + fn get_filename(&self) -> String { + format!( + "{}-{}", + self.get_name(), + self.id.as_deref().unwrap_or("unknown") + ) + } +} + +impl ResourceMeta for ComponentRepresentation { + fn label() -> &'static str { + "components" + } + fn secret_prefix() -> &'static str { + "component" + } } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/plan.rs b/src/plan.rs index 21a3f10..d627df2 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -6,8 +6,9 @@ use crate::models::{ UserRepresentation, }; +use crate::utils::ui::{CHECK, MEMO, SEARCH, SPARKLE, WARN}; use anyhow::{Context, Result}; -use console::{Emoji, Style, style}; +use console::{Style, style}; use serde::Serialize; use similar::{ChangeTag, TextDiff}; use std::collections::HashMap; @@ -16,9 +17,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs as async_fs; -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); -static ACTION: Emoji<'_, '_> = Emoji("🔍 ", "> "); - pub async fn run( client: &KeycloakClient, workspace_dir: PathBuf, @@ -67,7 +65,7 @@ pub async fn run( let realm_dir = workspace_dir.join(&realm_name); println!( "\n{} {}", - ACTION, + SEARCH, style(format!("Planning changes for realm: {}", realm_name)) .cyan() .bold() @@ -79,6 +77,7 @@ pub async fn run( interactive, Arc::clone(&env_vars), &mut changed_files, + &realm_name, ) .await?; } @@ -103,6 +102,7 @@ async fn plan_single_realm( interactive: bool, env_vars: Arc>, changed_files: &mut Vec, + realm_name: &str, ) -> Result<()> { // 1. Plan Realm plan_realm( @@ -112,6 +112,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -123,6 +124,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -134,6 +136,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -145,6 +148,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -156,6 +160,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -167,6 +172,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -178,6 +184,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -189,6 +196,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -200,6 +208,7 @@ async fn plan_single_realm( interactive, Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; @@ -212,6 +221,7 @@ async fn plan_single_realm( "components", Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; plan_components_or_keys( @@ -222,6 +232,7 @@ async fn plan_single_realm( "keys", Arc::clone(&env_vars), changed_files, + realm_name, ) .await?; check_keys_drift(client, changes_only).await?; @@ -254,7 +265,7 @@ fn print_diff( let changed = diff.ratio() < 1.0; if changed { - println!("\n{} Changes for {}:", Emoji("📝", ""), name); + println!("\n{} Changes for {}:", MEMO, name); for change in diff.iter_all_changes() { let (sign, style) = match change.tag() { ChangeTag::Delete => ("-", Style::new().red()), @@ -264,7 +275,7 @@ fn print_diff( print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change)); } } else if !changes_only { - println!("{} No changes for {}", Emoji("✅", ""), name); + println!("{} No changes for {}", CHECK, name); } Ok(changed) } @@ -276,10 +287,14 @@ async fn plan_client_scopes( 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?; + 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))) @@ -316,7 +331,7 @@ async fn plan_client_scopes( } else { println!( "\n{} Will create ClientScope: {}", - Emoji("✨", ""), + SPARKLE, local_scope.get_name() ); print_diff( @@ -355,10 +370,14 @@ async fn plan_groups( 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?; + 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))) @@ -395,7 +414,7 @@ async fn plan_groups( } else { println!( "\n{} Will create Group: {}", - Emoji("✨", ""), + SPARKLE, local_group.get_name() ); print_diff( @@ -434,10 +453,14 @@ async fn plan_users( 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?; + 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))) @@ -472,11 +495,7 @@ async fn plan_users( "user", )? } else { - println!( - "\n{} Will create User: {}", - Emoji("✨", ""), - local_user.get_name() - ); + println!("\n{} Will create User: {}", SPARKLE, local_user.get_name()); print_diff( &format!("User {}", local_user.get_name()), None::<&UserRepresentation>, @@ -513,10 +532,16 @@ async fn plan_authentication_flows( 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?; + 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))) @@ -553,7 +578,7 @@ async fn plan_authentication_flows( } else { println!( "\n{} Will create AuthenticationFlow: {}", - Emoji("✨", ""), + SPARKLE, local_flow.get_name() ); print_diff( @@ -592,10 +617,13 @@ async fn plan_required_actions( 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?; + 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() @@ -620,10 +648,9 @@ async fn plan_required_actions( let remote = existing_actions_map.get(&identity); let changed = if let Some(remote) = remote { - let remote_clone = remote.clone(); print_diff( &format!("RequiredAction {}", local_action.get_name()), - Some(&remote_clone), + Some(remote), &local_action, changes_only, "action", @@ -631,7 +658,7 @@ async fn plan_required_actions( } else { println!( "\n{} Will create RequiredAction: {}", - Emoji("✨", ""), + SPARKLE, local_action.get_name() ); print_diff( @@ -663,6 +690,7 @@ async fn plan_required_actions( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn plan_components_or_keys( client: &KeycloakClient, workspace_dir: &Path, @@ -671,10 +699,14 @@ async fn plan_components_or_keys( 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?; + 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, @@ -748,7 +780,7 @@ async fn plan_components_or_keys( } else { println!( "\n{} Will create Component: {}", - Emoji("✨", ""), + SPARKLE, local_component.get_name() ); let prefix = if dir_name == "keys" { @@ -792,6 +824,7 @@ async fn plan_realm( 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? { @@ -811,7 +844,9 @@ async fn plan_realm( if e.to_string().contains("404") { None } else { - return Err(e); + return Err(e).with_context(|| { + format!("Failed to get realm '{}' from Keycloak", realm_name) + }); } } }; @@ -846,10 +881,14 @@ async fn plan_roles( 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?; + 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))) @@ -886,11 +925,7 @@ async fn plan_roles( "role", )? } else { - println!( - "\n{} Will create Role: {}", - Emoji("✨", ""), - local_role.get_name() - ); + println!("\n{} Will create Role: {}", SPARKLE, local_role.get_name()); print_diff( &format!("Role {}", local_role.get_name()), None::<&RoleRepresentation>, @@ -927,10 +962,14 @@ async fn plan_clients( 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?; + 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))) @@ -967,7 +1006,7 @@ async fn plan_clients( } else { println!( "\n{} Will create Client: {}", - Emoji("✨", ""), + SPARKLE, local_client.get_name() ); print_diff( @@ -1006,10 +1045,16 @@ async fn plan_identity_providers( 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?; + 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))) @@ -1046,7 +1091,7 @@ async fn plan_identity_providers( } else { println!( "\n{} Will create IdentityProvider: {}", - Emoji("✨", ""), + SPARKLE, local_idp.get_name() ); print_diff( @@ -1106,7 +1151,7 @@ async fn check_keys_drift(client: &KeycloakClient, changes_only: bool) -> Result let provider_id = key.provider_id.as_deref().unwrap_or("unknown"); println!( "{} Warning: Active key (providerId: {}) is near expiration or expired! Consider rotating keys.", - Emoji("⚠️", ""), + WARN, style(provider_id).yellow() ); } diff --git a/src/utils.rs b/src/utils.rs index 6bfe205..b1d806c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ pub mod secrets; +pub mod ui; use anyhow::Context; use serde::Serialize; diff --git a/src/utils/secrets.rs b/src/utils/secrets.rs index 1e0f8b7..ae74b49 100644 --- a/src/utils/secrets.rs +++ b/src/utils/secrets.rs @@ -237,7 +237,7 @@ mod tests { secrets.get("KEYCLOAK_CLIENT_MY_CLIENT_CLIENTSECRET"), Some(&"my_super_secret".to_string()) ); - assert!(secrets.get("KEYCLOAK_CLIENT_STORETOKEN").is_none()); + assert!(!secrets.contains_key("KEYCLOAK_CLIENT_STORETOKEN")); } #[test] diff --git a/src/utils/ui.rs b/src/utils/ui.rs new file mode 100644 index 0000000..fc4d6fa --- /dev/null +++ b/src/utils/ui.rs @@ -0,0 +1,13 @@ +use console::Emoji; + +pub static ACTION: Emoji<'_, '_> = Emoji("🚀 ", ">> "); +pub static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", "> "); +pub static CHECK: Emoji<'_, '_> = Emoji("✅ ", "√ "); +pub static SUCCESS: Emoji<'_, '_> = Emoji("🎉 ", "* "); +pub static SUCCESS_CREATE: Emoji<'_, '_> = Emoji("✨ ", "+ "); +pub static SUCCESS_UPDATE: Emoji<'_, '_> = Emoji("🔄 ", "~ "); +pub static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); +pub static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); +pub static INFO: Emoji<'_, '_> = Emoji("💡 ", "i "); +pub static SPARKLE: Emoji<'_, '_> = Emoji("✨", ""); +pub static MEMO: Emoji<'_, '_> = Emoji("📝", ""); diff --git a/src/validate.rs b/src/validate.rs index b51447e..a6aef47 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -4,26 +4,26 @@ use crate::models::{ RealmRepresentation, RequiredActionProviderRepresentation, RoleRepresentation, UserRepresentation, }; +use crate::utils::ui::{CHECK, SEARCH, SUCCESS, WARN}; use anyhow::{Context, Result}; -use console::{Emoji, style}; +use console::style; use serde::de::DeserializeOwned; use std::collections::HashSet; -use std::fs; use std::path::{Path, PathBuf}; +use tokio::fs; -static CHECK: Emoji<'_, '_> = Emoji("✅ ", "√ "); -static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", "> "); -static SUCCESS: Emoji<'_, '_> = Emoji("🎉 ", "* "); -static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); - -fn read_yaml_files(dir: &Path, file_type: &str) -> Result> { +async fn read_yaml_files( + dir: &Path, + file_type: &str, +) -> Result> { let mut results = Vec::new(); - if dir.exists() { - for entry in fs::read_dir(dir)? { - let entry = entry?; + if fs::try_exists(dir).await? { + let mut entries = fs::read_dir(dir).await?; + 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))?; @@ -34,16 +34,16 @@ fn read_yaml_files(dir: &Path, file_type: &str) -> Result Result<()> { - if !workspace_dir.exists() { +pub async fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> { + if !fs::try_exists(&workspace_dir).await? { anyhow::bail!("Input directory {:?} does not exist", workspace_dir); } let realms = if realms_to_validate.is_empty() { let mut dirs = Vec::new(); - for entry in fs::read_dir(&workspace_dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { + let mut entries = fs::read_dir(&workspace_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await?.is_dir() { dirs.push(entry.file_name().to_string_lossy().to_string()); } } @@ -74,7 +74,7 @@ pub fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> .bold() ); let realm_dir = workspace_dir.join(realm_name); - validate_realm(realm_dir)?; + validate_realm(realm_dir).await?; println!( " {} {}", SUCCESS, @@ -86,13 +86,15 @@ pub fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> Ok(()) } -fn validate_realm(workspace_dir: PathBuf) -> Result<()> { +async fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 1. Validate Realm let realm_path = workspace_dir.join("realm.yaml"); - if !realm_path.exists() { + if !fs::try_exists(&realm_path).await? { anyhow::bail!("realm.yaml not found in {:?}", workspace_dir); } - let realm_content = fs::read_to_string(&realm_path).context("Failed to read realm.yaml")?; + let realm_content = fs::read_to_string(&realm_path) + .await + .context("Failed to read realm.yaml")?; let realm: RealmRepresentation = serde_yaml::from_str(&realm_content).context("Failed to parse realm.yaml")?; @@ -109,7 +111,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 2. Validate Roles let roles_dir = workspace_dir.join("roles"); let mut role_names = HashSet::new(); - let roles: Vec<(PathBuf, RoleRepresentation)> = read_yaml_files(&roles_dir, "role")?; + let roles: Vec<(PathBuf, RoleRepresentation)> = read_yaml_files(&roles_dir, "role").await?; for (path, role) in &roles { if role.name.is_empty() { @@ -129,7 +131,8 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 3. Validate Clients let clients_dir = workspace_dir.join("clients"); - let clients: Vec<(PathBuf, ClientRepresentation)> = read_yaml_files(&clients_dir, "client")?; + let clients: Vec<(PathBuf, ClientRepresentation)> = + read_yaml_files(&clients_dir, "client").await?; for (path, client) in &clients { if client.client_id.is_none() || client.client_id.as_deref().unwrap_or("").is_empty() { @@ -145,7 +148,8 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 4. Validate Identity Providers let idps_dir = workspace_dir.join("identity-providers"); - let idps: Vec<(PathBuf, IdentityProviderRepresentation)> = read_yaml_files(&idps_dir, "idp")?; + let idps: Vec<(PathBuf, IdentityProviderRepresentation)> = + read_yaml_files(&idps_dir, "idp").await?; for (path, idp) in &idps { if idp.alias.is_none() || idp.alias.as_deref().unwrap_or("").is_empty() { @@ -168,7 +172,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 5. Validate Client Scopes let scopes_dir = workspace_dir.join("client-scopes"); let scopes: Vec<(PathBuf, ClientScopeRepresentation)> = - read_yaml_files(&scopes_dir, "client-scope")?; + read_yaml_files(&scopes_dir, "client-scope").await?; for (path, scope) in &scopes { if scope.name.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Client Scope name is missing or empty in {:?}", path); @@ -183,7 +187,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 6. Validate Groups let groups_dir = workspace_dir.join("groups"); - let groups: Vec<(PathBuf, GroupRepresentation)> = read_yaml_files(&groups_dir, "group")?; + let groups: Vec<(PathBuf, GroupRepresentation)> = read_yaml_files(&groups_dir, "group").await?; for (path, group) in &groups { if group.name.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Group name is missing or empty in {:?}", path); @@ -198,7 +202,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 7. Validate Users let users_dir = workspace_dir.join("users"); - let users: Vec<(PathBuf, UserRepresentation)> = read_yaml_files(&users_dir, "user")?; + let users: Vec<(PathBuf, UserRepresentation)> = read_yaml_files(&users_dir, "user").await?; for (path, user) in &users { if user.username.as_deref().unwrap_or("").is_empty() { anyhow::bail!("User username is missing or empty in {:?}", path); @@ -214,7 +218,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 8. Validate Authentication Flows let flows_dir = workspace_dir.join("authentication-flows"); let flows: Vec<(PathBuf, AuthenticationFlowRepresentation)> = - read_yaml_files(&flows_dir, "authentication-flow")?; + read_yaml_files(&flows_dir, "authentication-flow").await?; for (path, flow) in &flows { if flow.alias.as_deref().unwrap_or("").is_empty() { anyhow::bail!( @@ -233,7 +237,7 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 9. Validate Required Actions let actions_dir = workspace_dir.join("required-actions"); let actions: Vec<(PathBuf, RequiredActionProviderRepresentation)> = - read_yaml_files(&actions_dir, "required-action")?; + read_yaml_files(&actions_dir, "required-action").await?; for (path, action) in &actions { if action.alias.as_deref().unwrap_or("").is_empty() { anyhow::bail!("Required Action alias is missing or empty in {:?}", path); @@ -255,9 +259,9 @@ fn validate_realm(workspace_dir: PathBuf) -> Result<()> { // 10. Validate Components and Keys for dir_name in ["components", "keys"].iter() { let dir = workspace_dir.join(dir_name); - if fs::exists(&dir)? { + if fs::try_exists(&dir).await? { let components: Vec<(PathBuf, ComponentRepresentation)> = - read_yaml_files(&dir, dir_name)?; + read_yaml_files(&dir, dir_name).await?; for (path, component) in &components { if let Some(name) = &component.name && name.is_empty() diff --git a/tests/client_test.rs b/tests/client_test.rs index d99c33a..b58c6cf 100644 --- a/tests/client_test.rs +++ b/tests/client_test.rs @@ -2,7 +2,6 @@ mod common; use app::client::KeycloakClient; use app::models::ClientRepresentation; use common::start_mock_server; -use tokio; #[tokio::test] async fn test_login_password_grant() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 939319f..395bb58 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,5 @@ use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tokio::net::TcpListener; #[derive(Deserialize, Debug)] @@ -12,12 +12,6 @@ pub struct TokenRequest { pub client_secret: Option, } -#[derive(Serialize)] -pub struct TokenResponse { - pub access_token: String, - pub expires_in: i32, -} - pub async fn start_mock_server() -> String { let app = Router::new() .route( @@ -122,21 +116,15 @@ pub async fn start_mock_server() -> String { } async fn token_handler(axum::Form(payload): axum::Form) -> impl IntoResponse { - if payload.grant_type == "password" + let is_password_grant = payload.grant_type == "password" && payload.username.as_deref() == Some("admin") - && payload.password.as_deref() == Some("admin") - { - ( - StatusCode::OK, - Json(serde_json::json!({ - "access_token": "mock_token", - "expires_in": 300 - })), - ) - } else if payload.grant_type == "client_credentials" + && payload.password.as_deref() == Some("admin"); + + let is_client_credentials_grant = payload.grant_type == "client_credentials" && payload.client_id == "admin-cli" - && payload.client_secret.as_deref() == Some("secret") - { + && payload.client_secret.as_deref() == Some("secret"); + + if is_password_grant || is_client_credentials_grant { ( StatusCode::OK, Json(serde_json::json!({ diff --git a/tests/coverage_test.rs b/tests/coverage_test.rs index 2b5d33c..6ddde6f 100644 --- a/tests/coverage_test.rs +++ b/tests/coverage_test.rs @@ -70,7 +70,7 @@ async fn test_plan_edge_cases() { // 6. Test with invalid YAML fs::write(realm_dir.join("invalid.yaml"), "invalid: [yaml").unwrap(); - let res = plan::run( + let _res = plan::run( &client, workspace_dir.clone(), false, diff --git a/tests/real_integration_test.rs b/tests/real_integration_test.rs index a5f714a..d9ed43f 100644 --- a/tests/real_integration_test.rs +++ b/tests/real_integration_test.rs @@ -12,7 +12,7 @@ impl DockerComposeGuard { fn new() -> Self { println!("Starting Keycloak with docker compose..."); let status = Command::new("docker") - .args(&["compose", "up", "-d", "--wait"]) + .args(["compose", "up", "-d", "--wait"]) .status() .expect("Failed to execute docker compose up"); @@ -27,7 +27,7 @@ impl Drop for DockerComposeGuard { fn drop(&mut self) { println!("Tearing down Keycloak..."); let _ = Command::new("docker") - .args(&["compose", "down", "-v"]) + .args(["compose", "down", "-v"]) .status(); } } diff --git a/tests/ultimate_coverage_test.rs b/tests/ultimate_coverage_test.rs index 9f0e427..2e278b8 100644 --- a/tests/ultimate_coverage_test.rs +++ b/tests/ultimate_coverage_test.rs @@ -2,7 +2,6 @@ mod common; use app::client::KeycloakClient; use app::{apply, plan}; use common::start_mock_server; -use serde_json::json; use std::fs; use tempfile::tempdir; diff --git a/tests/validate_test.rs b/tests/validate_test.rs index 82494b8..f97c29a 100644 --- a/tests/validate_test.rs +++ b/tests/validate_test.rs @@ -8,8 +8,8 @@ use app::validate; use std::fs; use tempfile::tempdir; -#[test] -fn test_validate() { +#[tokio::test] +async fn test_validate() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -28,12 +28,12 @@ fn test_validate() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_ok()); } -#[test] -fn test_validate_empty_role_name() { +#[tokio::test] +async fn test_validate_empty_role_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -72,7 +72,7 @@ fn test_validate_empty_role_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -82,8 +82,8 @@ fn test_validate_empty_role_name() { ); } -#[test] -fn test_validate_duplicate_role_name() { +#[tokio::test] +async fn test_validate_duplicate_role_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -138,7 +138,7 @@ fn test_validate_duplicate_role_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -148,14 +148,14 @@ fn test_validate_duplicate_role_name() { ); } -#[test] -fn test_validate_missing_realm() { +#[tokio::test] +async fn test_validate_missing_realm() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); std::fs::create_dir_all(&realm_dir).unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -165,8 +165,8 @@ fn test_validate_missing_realm() { ); } -#[test] -fn test_validate_empty_client_id() { +#[tokio::test] +async fn test_validate_empty_client_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -210,7 +210,7 @@ fn test_validate_empty_client_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -220,8 +220,8 @@ fn test_validate_empty_client_id() { ); } -#[test] -fn test_validate_empty_idp_alias() { +#[tokio::test] +async fn test_validate_empty_idp_alias() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -265,7 +265,7 @@ fn test_validate_empty_idp_alias() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -275,8 +275,8 @@ fn test_validate_empty_idp_alias() { ); } -#[test] -fn test_validate_empty_idp_provider_id() { +#[tokio::test] +async fn test_validate_empty_idp_provider_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -320,7 +320,7 @@ fn test_validate_empty_idp_provider_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -330,8 +330,8 @@ fn test_validate_empty_idp_provider_id() { ); } -#[test] -fn test_validate_empty_client_scope_name() { +#[tokio::test] +async fn test_validate_empty_client_scope_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -366,7 +366,7 @@ fn test_validate_empty_client_scope_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -376,8 +376,8 @@ fn test_validate_empty_client_scope_name() { ); } -#[test] -fn test_validate_empty_group_name() { +#[tokio::test] +async fn test_validate_empty_group_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -411,7 +411,7 @@ fn test_validate_empty_group_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -421,8 +421,8 @@ fn test_validate_empty_group_name() { ); } -#[test] -fn test_validate_empty_username() { +#[tokio::test] +async fn test_validate_empty_username() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -460,7 +460,7 @@ fn test_validate_empty_username() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -470,8 +470,8 @@ fn test_validate_empty_username() { ); } -#[test] -fn test_validate_empty_auth_flow_alias() { +#[tokio::test] +async fn test_validate_empty_auth_flow_alias() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -508,7 +508,7 @@ fn test_validate_empty_auth_flow_alias() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -518,8 +518,8 @@ fn test_validate_empty_auth_flow_alias() { ); } -#[test] -fn test_validate_empty_required_action_alias() { +#[tokio::test] +async fn test_validate_empty_required_action_alias() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -556,7 +556,7 @@ fn test_validate_empty_required_action_alias() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -566,8 +566,8 @@ fn test_validate_empty_required_action_alias() { ); } -#[test] -fn test_validate_empty_required_action_provider_id() { +#[tokio::test] +async fn test_validate_empty_required_action_provider_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -604,7 +604,7 @@ fn test_validate_empty_required_action_provider_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -614,8 +614,8 @@ fn test_validate_empty_required_action_provider_id() { ); } -#[test] -fn test_validate_empty_component_name() { +#[tokio::test] +async fn test_validate_empty_component_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -652,7 +652,7 @@ fn test_validate_empty_component_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -662,8 +662,8 @@ fn test_validate_empty_component_name() { ); } -#[test] -fn test_validate_missing_component_name() { +#[tokio::test] +async fn test_validate_missing_component_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -700,7 +700,7 @@ fn test_validate_missing_component_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!( result.is_ok(), "Validation should succeed for missing component name. Error: {:?}", @@ -708,8 +708,8 @@ fn test_validate_missing_component_name() { ); } -#[test] -fn test_validate_empty_component_provider_id() { +#[tokio::test] +async fn test_validate_empty_component_provider_id() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -746,7 +746,7 @@ fn test_validate_empty_component_provider_id() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result @@ -756,8 +756,8 @@ fn test_validate_empty_component_provider_id() { ); } -#[test] -fn test_validate_empty_realm_name() { +#[tokio::test] +async fn test_validate_empty_realm_name() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); let realm_dir = workspace_dir.join("test-realm"); @@ -776,7 +776,7 @@ fn test_validate_empty_realm_name() { ) .unwrap(); - let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]); + let result = validate::run(workspace_dir.clone(), &["test-realm".to_string()]).await; assert!(result.is_err()); assert!( result