diff --git a/tembo-cli/src/cmd/logs.rs b/tembo-cli/src/cmd/logs.rs new file mode 100644 index 000000000..3588b0b4e --- /dev/null +++ b/tembo-cli/src/cmd/logs.rs @@ -0,0 +1,171 @@ +use crate::apply::{get_instance_id, get_instance_settings}; +use crate::cli::context::{get_current_context, Environment, Profile}; +use anyhow::{anyhow, Result}; +use clap::Args; +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use serde::{Deserialize, Serialize}; +use temboclient::apis::configuration::Configuration; + +#[derive(Args)] +pub struct LogsCommand {} + +#[derive(Serialize, Deserialize, Debug)] +struct LogStream { + app: String, + container: String, + pod: String, + stream: String, + tembo_instance_id: String, + tembo_organization_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct LogEntry { + stream: LogStream, + values: Vec>, +} + +#[derive(Serialize, Deserialize, Debug)] +struct LogResult { + resultType: String, + result: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +struct LogData { + status: String, + data: LogResult, +} + +#[derive(Serialize, Deserialize, Debug)] +struct IndividualLogEntry { + ts: String, + msg: String, +} + +fn beautify_logs(json_data: &str) -> Result<()> { + let log_data: LogData = serde_json::from_str(json_data)?; + + for entry in log_data.data.result { + for value in entry.values { + let log = &value[1]; + + match serde_json::from_str::(log) { + Ok(log_entry) => println!("{}", format_log_entry(&log_entry)), + Err(_) => println!("{}", log), + } + } + } + + Ok(()) +} + +fn format_log_entry(log_entry: &IndividualLogEntry) -> String { + format!("{} {}", log_entry.ts, log_entry.msg) +} + +pub fn execute() -> Result<()> { + let env = get_current_context()?; + let org_id = env.org_id.clone().unwrap_or_default(); + let profile = env.selected_profile.clone().unwrap(); + let tembo_data_host = profile.clone().tembo_data_host; + + let config = Configuration { + base_path: profile.tembo_host, + bearer_access_token: Some(profile.tembo_access_token.clone()), + ..Default::default() + }; + + let instance_settings = get_instance_settings(None, None)?; + + let client = Client::new(); + let mut headers = HeaderMap::new(); + headers.insert("X-Scope-OrgID", HeaderValue::from_str(&org_id)?); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", profile.tembo_access_token))?, + ); + + for (_key, value) in instance_settings.iter() { + let instance_id_option = + get_instance_id(value.instance_name.clone(), &config, env.clone())?; + + let instance_id = if let Some(id) = instance_id_option { + id + } else { + eprintln!("Instance ID not found for {}", value.instance_name); + continue; + }; + + let query = format!("{{tembo_instance_id=\"{}\"}}", instance_id); + let url = format!("{}/loki/api/v1/query_range", tembo_data_host); + + let response = client + .get(url) + .headers(headers.clone()) + .query(&[("query", &query)]) + .send()?; + + if response.status().is_success() { + let response_body = response.text()?; + beautify_logs(&response_body)?; + } else { + eprintln!("Error: {:?}", response.status()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_query(query: &str) -> Result { + match query { + "valid_json" => Ok(r#"{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "stream": { + "app": "test_app", + "container": "test_container", + "pod": "test_pod", + "stream": "test_stream", + "tembo_instance_id": "test_id", + "tembo_organization_id": "test_org_id" + }, + "values": [ + ["1234567890", "{\"ts\":\"2024-01-24T21:37:53Z\",\"msg\":\"Valid JSON log entry\"}"] + ] + }, + { + "stream": { + "app": "test_app", + "container": "test_container", + "pod": "test_pod", + "stream": "test_stream", + "tembo_instance_id": "test_id", + "tembo_organization_id": "test_org_id" + }, + "values": [ + ["1234567890", "Non-JSON log entry"] + ] + } + ] + } + }"#.to_string()), + _ => Err(anyhow!("Invalid query")), + } + } + + #[tokio::test] + async fn cloud_logs() { + let valid_json_log = mock_query("valid_json").unwrap(); + let result = beautify_logs(&valid_json_log); + assert!(result.is_ok()); + } +} diff --git a/tembo-cli/src/cmd/mod.rs b/tembo-cli/src/cmd/mod.rs index 37f51abff..ff5811910 100644 --- a/tembo-cli/src/cmd/mod.rs +++ b/tembo-cli/src/cmd/mod.rs @@ -2,4 +2,5 @@ pub mod apply; pub mod context; pub mod delete; pub mod init; +pub mod logs; pub mod validate; diff --git a/tembo-cli/src/main.rs b/tembo-cli/src/main.rs index d601e4ee8..f2ef5cd84 100644 --- a/tembo-cli/src/main.rs +++ b/tembo-cli/src/main.rs @@ -1,10 +1,11 @@ use crate::cmd::delete::DeleteCommand; use crate::cmd::validate::ValidateCommand; -use crate::cmd::{apply, context, delete, init, validate}; +use crate::cmd::{apply, context, delete, init, logs, validate}; use clap::{crate_authors, crate_version, Args, Parser, Subcommand}; use cmd::apply::ApplyCommand; use cmd::context::{ContextCommand, ContextSubCommand}; use cmd::init::InitCommand; +use cmd::logs::LogsCommand; mod cli; mod cmd; @@ -28,6 +29,7 @@ enum SubCommands { Apply(ApplyCommand), Validate(ValidateCommand), Delete(DeleteCommand), + Logs(LogsCommand), } #[derive(Args)] @@ -62,6 +64,9 @@ fn main() -> Result<(), anyhow::Error> { SubCommands::Validate(_validate_cmd) => { validate::execute(app.global_opts.verbose)?; } + SubCommands::Logs(_logs_cmd) => { + logs::execute()?; + } SubCommands::Delete(_delete_cmd) => { delete::execute()?; }