diff --git a/core/cli/src/args/context.rs b/core/cli/src/args/context.rs index 1c7d9089ca..50ef254c84 100644 --- a/core/cli/src/args/context.rs +++ b/core/cli/src/args/context.rs @@ -60,6 +60,17 @@ pub(crate) enum ContextAction { /// iggy context delete production #[clap(verbatim_doc_comment, visible_alias = "d")] Delete(ContextDeleteArgs), + + /// Show details of a specific context + /// + /// Displays the full configuration of a named context including + /// transport, server addresses, TLS settings, and credentials. + /// + /// Examples + /// iggy context show default + /// iggy context show production + #[clap(verbatim_doc_comment, visible_alias = "s")] + Show(ContextShowArgs), } #[derive(Debug, Clone, Args)] @@ -148,6 +159,13 @@ pub(crate) struct ContextDeleteArgs { pub(crate) context_name: String, } +#[derive(Debug, Clone, Args)] +pub(crate) struct ContextShowArgs { + /// Name of the context to show + #[arg(value_parser = clap::value_parser!(String))] + pub(crate) context_name: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/cli/src/args/mod.rs b/core/cli/src/args/mod.rs index 428d4d5e4c..a9ed501606 100644 --- a/core/cli/src/args/mod.rs +++ b/core/cli/src/args/mod.rs @@ -45,6 +45,9 @@ use crate::args::{ #[cfg(feature = "login-session")] use crate::args::system::LoginArgs; +#[cfg(feature = "login-session")] +use crate::args::session::SessionAction; + use self::user::UserAction; pub(crate) mod client; @@ -63,6 +66,9 @@ pub(crate) mod system; pub(crate) mod topic; pub(crate) mod user; +#[cfg(feature = "login-session")] +pub(crate) mod session; + static CARGO_BIN_NAME: &str = env!("CARGO_BIN_NAME"); static CARGO_PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE"); @@ -206,6 +212,10 @@ pub(crate) enum Command { /// execute any command that requires authentication. #[clap(verbatim_doc_comment, visible_alias = "lo")] Logout, + #[cfg(feature = "login-session")] + /// login session operations + #[command(subcommand, visible_alias = "sess")] + Session(SessionAction), } impl IggyConsoleArgs { diff --git a/core/cli/src/args/session.rs b/core/cli/src/args/session.rs new file mode 100644 index 0000000000..adc739129a --- /dev/null +++ b/core/cli/src/args/session.rs @@ -0,0 +1,33 @@ +/* Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use clap::Subcommand; + +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum SessionAction { + /// Show current login session status + /// + /// Displays whether you have an active login session and which + /// server it is connected to. This checks the local credential + /// store without contacting the server. + /// + /// Examples + /// iggy session status + #[clap(verbatim_doc_comment, visible_alias = "s")] + Status, +} diff --git a/core/cli/src/commands/binary_context/mod.rs b/core/cli/src/commands/binary_context/mod.rs index bad021e212..8037ca5660 100644 --- a/core/cli/src/commands/binary_context/mod.rs +++ b/core/cli/src/commands/binary_context/mod.rs @@ -21,4 +21,5 @@ pub mod common; pub mod create_context; pub mod delete_context; pub mod get_contexts; +pub mod show_context; pub mod use_context; diff --git a/core/cli/src/commands/binary_context/show_context.rs b/core/cli/src/commands/binary_context/show_context.rs new file mode 100644 index 0000000000..21cf456071 --- /dev/null +++ b/core/cli/src/commands/binary_context/show_context.rs @@ -0,0 +1,114 @@ +/* Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use anyhow::bail; +use async_trait::async_trait; +use comfy_table::Table; +use tracing::{Level, event}; + +use crate::commands::cli_command::{CliCommand, PRINT_TARGET}; +use iggy_common::Client; + +use super::common::{ContextConfig, ContextManager}; + +const MASKED_VALUE: &str = "********"; + +pub struct ShowContextCmd { + context_name: String, +} + +impl ShowContextCmd { + pub fn new(context_name: String) -> Self { + Self { context_name } + } + + fn build_table(name: &str, is_active: bool, config: &ContextConfig) -> Table { + let mut table = Table::new(); + table.set_header(vec!["Property", "Value"]); + + let display_name = if is_active { + format!("{name}*") + } else { + name.to_string() + }; + table.add_row(vec!["Name", &display_name]); + + if let Some(ref transport) = config.iggy.transport { + table.add_row(vec!["Transport", transport]); + } + if let Some(ref addr) = config.iggy.tcp_server_address { + table.add_row(vec!["TCP Server Address", addr]); + } + if let Some(ref url) = config.iggy.http_api_url { + table.add_row(vec!["HTTP API URL", url]); + } + if let Some(ref addr) = config.iggy.quic_server_address { + table.add_row(vec!["QUIC Server Address", addr]); + } + if let Some(tls) = config.iggy.tcp_tls_enabled { + table.add_row(vec!["TCP TLS Enabled", &tls.to_string()]); + } + if let Some(ref username) = config.username { + table.add_row(vec!["Username", username]); + } + if config.password.is_some() { + table.add_row(vec!["Password", MASKED_VALUE]); + } + if config.token.is_some() { + table.add_row(vec!["Token", MASKED_VALUE]); + } + if let Some(ref token_name) = config.token_name { + table.add_row(vec!["Token Name", token_name]); + } + + table + } +} + +#[async_trait] +impl CliCommand for ShowContextCmd { + fn explain(&self) -> String { + format!("show context \"{}\"", self.context_name) + } + + fn login_required(&self) -> bool { + false + } + + fn connection_required(&self) -> bool { + false + } + + async fn execute_cmd(&mut self, _client: &dyn Client) -> anyhow::Result<(), anyhow::Error> { + let mut context_mgr = ContextManager::default(); + let contexts_map = context_mgr.get_contexts().await?; + let active_context_key = context_mgr.get_active_context_key().await?; + + let config = match contexts_map.get(&self.context_name) { + Some(config) => config, + None => bail!("context '{}' not found", self.context_name), + }; + + let is_active = self.context_name == active_context_key; + let table = Self::build_table(&self.context_name, is_active, config); + + event!(target: PRINT_TARGET, Level::INFO, "{table}"); + + Ok(()) + } +} diff --git a/core/cli/src/commands/binary_system/mod.rs b/core/cli/src/commands/binary_system/mod.rs index 18e57b5c34..42c0bc1dc5 100644 --- a/core/cli/src/commands/binary_system/mod.rs +++ b/core/cli/src/commands/binary_system/mod.rs @@ -21,5 +21,6 @@ pub mod logout; pub mod me; pub mod ping; pub mod session; +pub mod session_status; pub mod snapshot; pub mod stats; diff --git a/core/cli/src/commands/binary_system/session_status.rs b/core/cli/src/commands/binary_system/session_status.rs new file mode 100644 index 0000000000..110cbcc5ab --- /dev/null +++ b/core/cli/src/commands/binary_system/session_status.rs @@ -0,0 +1,70 @@ +/* Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use crate::commands::binary_system::session::ServerSession; +use crate::commands::cli_command::{CliCommand, PRINT_TARGET}; +use async_trait::async_trait; +use comfy_table::Table; +use iggy_common::Client; +use tracing::{Level, event}; + +pub struct SessionStatusCmd { + server_session: ServerSession, +} + +impl SessionStatusCmd { + pub fn new(server_address: String) -> Self { + Self { + server_session: ServerSession::new(server_address), + } + } +} + +#[async_trait] +impl CliCommand for SessionStatusCmd { + fn explain(&self) -> String { + "session status command".to_owned() + } + + fn login_required(&self) -> bool { + false + } + + fn connection_required(&self) -> bool { + false + } + + async fn execute_cmd(&mut self, _client: &dyn Client) -> anyhow::Result<(), anyhow::Error> { + let is_active = self.server_session.is_active(); + let server_address = self.server_session.get_server_address(); + + let mut table = Table::new(); + table.set_header(vec!["Property", "Value"]); + table.add_row(vec!["Server Address", server_address]); + + if is_active { + table.add_row(vec!["Session Active", "Yes"]); + } else { + table.add_row(vec!["Session Active", "No"]); + } + + event!(target: PRINT_TARGET, Level::INFO, "{table}"); + + Ok(()) + } +} diff --git a/core/cli/src/main.rs b/core/cli/src/main.rs index 98c300adac..543003dacd 100644 --- a/core/cli/src/main.rs +++ b/core/cli/src/main.rs @@ -43,6 +43,7 @@ use iggy::prelude::{Aes256GcmEncryptor, Args, EncryptorKind, PersonalAccessToken use iggy_cli::commands::binary_context::common::ContextManager; use iggy_cli::commands::binary_context::create_context::CreateContextCmd; use iggy_cli::commands::binary_context::delete_context::DeleteContextCmd; +use iggy_cli::commands::binary_context::show_context::ShowContextCmd; use iggy_cli::commands::binary_context::use_context::UseContextCmd; use iggy_cli::commands::binary_segments::delete_segments::DeleteSegmentsCmd; use iggy_cli::commands::binary_system::snapshot::GetSnapshotCmd; @@ -95,6 +96,8 @@ use tracing::{Level, event}; #[cfg(feature = "login-session")] mod main_login_session { + pub(crate) use crate::args::session::SessionAction; + pub(crate) use iggy_cli::commands::binary_system::session_status::SessionStatusCmd; pub(crate) use iggy_cli::commands::binary_system::{login::LoginCmd, logout::LogoutCmd}; pub(crate) use iggy_cli::commands::utils::login_session_expiry::LoginSessionExpiry; } @@ -337,6 +340,9 @@ fn get_command( ContextAction::Delete(delete_args) => { Box::new(DeleteContextCmd::new(delete_args.context_name.clone())) } + ContextAction::Show(show_args) => { + Box::new(ShowContextCmd::new(show_args.context_name.clone())) + } }, #[cfg(feature = "login-session")] Command::Login(login_args) => Box::new(LoginCmd::new( @@ -345,6 +351,12 @@ fn get_command( )), #[cfg(feature = "login-session")] Command::Logout => Box::new(LogoutCmd::new(iggy_args.get_server_address().unwrap())), + #[cfg(feature = "login-session")] + Command::Session(command) => match command { + SessionAction::Status => Box::new(SessionStatusCmd::new( + iggy_args.get_server_address().unwrap(), + )), + }, } } diff --git a/core/integration/tests/cli/context/mod.rs b/core/integration/tests/cli/context/mod.rs index 1bc857c6c8..01ca1a7470 100644 --- a/core/integration/tests/cli/context/mod.rs +++ b/core/integration/tests/cli/context/mod.rs @@ -22,4 +22,5 @@ mod test_context_applied; mod test_context_create_command; mod test_context_delete_command; mod test_context_list_command; +mod test_context_show_command; mod test_context_use_command; diff --git a/core/integration/tests/cli/context/test_context_show_command.rs b/core/integration/tests/cli/context/test_context_show_command.rs new file mode 100644 index 0000000000..642dada097 --- /dev/null +++ b/core/integration/tests/cli/context/test_context_show_command.rs @@ -0,0 +1,274 @@ +/* Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::collections::BTreeMap; + +use crate::cli::common::{ + CLAP_INDENT, IggyCmdCommand, IggyCmdTest, IggyCmdTestCase, TestHelpCmd, USAGE_PREFIX, +}; +use assert_cmd::assert::Assert; +use async_trait::async_trait; +use iggy::prelude::Client; +use iggy_cli::commands::binary_context::common::ContextConfig; +use iggy_common::ArgsOptional; +use predicates::str::contains; +use serial_test::parallel; + +use super::common::TestIggyContext; + +struct TestContextShowCmd { + test_iggy_context: TestIggyContext, + context_to_show: String, + expected_fields: Vec<(String, String)>, +} + +impl TestContextShowCmd { + fn new( + test_iggy_context: TestIggyContext, + context_to_show: String, + expected_fields: Vec<(String, String)>, + ) -> Self { + Self { + test_iggy_context, + context_to_show, + expected_fields, + } + } +} + +#[async_trait] +impl IggyCmdTestCase for TestContextShowCmd { + async fn prepare_server_state(&mut self, _client: &dyn Client) { + self.test_iggy_context.prepare().await; + } + + fn get_command(&self) -> IggyCmdCommand { + IggyCmdCommand::new() + .env( + "IGGY_HOME", + self.test_iggy_context.get_iggy_home().to_str().unwrap(), + ) + .arg("context") + .arg("show") + .arg(self.context_to_show.clone()) + .with_env_credentials() + } + + fn verify_command(&self, command_state: Assert) { + let mut command_state = command_state.success(); + + for (key, value) in &self.expected_fields { + command_state = command_state + .stdout(contains(key.as_str())) + .stdout(contains(value.as_str())); + } + } + + async fn verify_server_state(&self, _client: &dyn Client) {} +} + +#[tokio::test] +#[parallel] +pub async fn should_show_context_with_all_fields() { + let mut iggy_cmd_test = IggyCmdTest::default(); + iggy_cmd_test.setup().await; + + let config = ContextConfig { + username: Some("admin".to_string()), + password: Some("secret".to_string()), + token: None, + token_name: None, + iggy: ArgsOptional { + transport: Some("tcp".to_string()), + tcp_server_address: Some("10.0.0.1:8090".to_string()), + tcp_tls_enabled: Some(true), + ..Default::default() + }, + extra: Default::default(), + }; + + iggy_cmd_test + .execute_test(TestContextShowCmd::new( + TestIggyContext::new( + Some(BTreeMap::from([ + ("default".to_string(), ContextConfig::default()), + ("production".to_string(), config), + ])), + None, + ), + "production".to_string(), + vec![ + ("Name".to_string(), "production".to_string()), + ("Transport".to_string(), "tcp".to_string()), + ( + "TCP Server Address".to_string(), + "10.0.0.1:8090".to_string(), + ), + ("TCP TLS Enabled".to_string(), "true".to_string()), + ("Username".to_string(), "admin".to_string()), + ("Password".to_string(), "********".to_string()), + ], + )) + .await; +} + +#[tokio::test] +#[parallel] +pub async fn should_show_default_context() { + let mut iggy_cmd_test = IggyCmdTest::default(); + iggy_cmd_test.setup().await; + + iggy_cmd_test + .execute_test(TestContextShowCmd::new( + TestIggyContext::new(None, None), + "default".to_string(), + vec![("Name".to_string(), "default*".to_string())], + )) + .await; +} + +#[tokio::test] +#[parallel] +pub async fn should_show_active_context_with_asterisk() { + let mut iggy_cmd_test = IggyCmdTest::default(); + iggy_cmd_test.setup().await; + + iggy_cmd_test + .execute_test(TestContextShowCmd::new( + TestIggyContext::new( + Some(BTreeMap::from([ + ("default".to_string(), ContextConfig::default()), + ("dev".to_string(), ContextConfig::default()), + ])), + Some("dev".to_string()), + ), + "dev".to_string(), + vec![("Name".to_string(), "dev*".to_string())], + )) + .await; +} + +struct TestContextShowNotFoundCmd { + test_iggy_context: TestIggyContext, + context_to_show: String, +} + +impl TestContextShowNotFoundCmd { + fn new(test_iggy_context: TestIggyContext, context_to_show: String) -> Self { + Self { + test_iggy_context, + context_to_show, + } + } +} + +#[async_trait] +impl IggyCmdTestCase for TestContextShowNotFoundCmd { + async fn prepare_server_state(&mut self, _client: &dyn Client) { + self.test_iggy_context.prepare().await; + } + + fn get_command(&self) -> IggyCmdCommand { + IggyCmdCommand::new() + .env( + "IGGY_HOME", + self.test_iggy_context.get_iggy_home().to_str().unwrap(), + ) + .arg("context") + .arg("show") + .arg(self.context_to_show.clone()) + .with_env_credentials() + } + + fn verify_command(&self, command_state: Assert) { + command_state.failure().stderr(contains("not found")); + } + + async fn verify_server_state(&self, _client: &dyn Client) {} +} + +#[tokio::test] +#[parallel] +pub async fn should_fail_for_nonexistent_context() { + let mut iggy_cmd_test = IggyCmdTest::default(); + iggy_cmd_test.setup().await; + + iggy_cmd_test + .execute_test(TestContextShowNotFoundCmd::new( + TestIggyContext::new(None, None), + "nonexistent".to_string(), + )) + .await; +} + +#[tokio::test] +#[parallel] +pub async fn should_help_match() { + let mut iggy_cmd_test = IggyCmdTest::default(); + + iggy_cmd_test + .execute_test_for_help_command(TestHelpCmd::new( + vec!["context", "show", "--help"], + format!( + r#"Show details of a specific context + +Displays the full configuration of a named context including +transport, server addresses, TLS settings, and credentials. + +Examples + iggy context show default + iggy context show production + +{USAGE_PREFIX} context show + +Arguments: + +{CLAP_INDENT}Name of the context to show + +Options: + -h, --help +{CLAP_INDENT}Print help (see a summary with '-h') +"#, + ), + )) + .await; +} + +#[tokio::test] +#[parallel] +pub async fn should_short_help_match() { + let mut iggy_cmd_test = IggyCmdTest::default(); + + iggy_cmd_test + .execute_test_for_help_command(TestHelpCmd::new( + vec!["context", "show", "-h"], + format!( + r#"Show details of a specific context + +{USAGE_PREFIX} context show + +Arguments: + Name of the context to show + +Options: + -h, --help Print help (see more with '--help') +"#, + ), + )) + .await; +} diff --git a/core/integration/tests/cli/general/test_help_command.rs b/core/integration/tests/cli/general/test_help_command.rs index 8cf0735b10..3653456962 100644 --- a/core/integration/tests/cli/general/test_help_command.rs +++ b/core/integration/tests/cli/general/test_help_command.rs @@ -51,6 +51,7 @@ Commands: context context operations [aliases: ctx] login login to Iggy server [aliases: li] logout logout from Iggy server [aliases: lo] + session login session operations [aliases: sess] help Print this message or the help of the given subcommand(s) Options: diff --git a/core/integration/tests/cli/general/test_overview_command.rs b/core/integration/tests/cli/general/test_overview_command.rs index a5a88d2e91..8a32f9dff2 100644 --- a/core/integration/tests/cli/general/test_overview_command.rs +++ b/core/integration/tests/cli/general/test_overview_command.rs @@ -62,6 +62,7 @@ Commands: context context operations [aliases: ctx] login login to Iggy server [aliases: li] logout logout from Iggy server [aliases: lo] + session login session operations [aliases: sess] help Print this message or the help of the given subcommand(s)