diff --git a/Cargo.lock b/Cargo.lock index 2b5f1ab..f8925aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,7 +709,7 @@ dependencies = [ [[package]] name = "cloud-openapi" version = "0.1.0" -source = "git+https://github.com/fermyon/cloud-openapi?rev=3e70369e3fc9574e262827332157da40da0a4f66#3e70369e3fc9574e262827332157da40da0a4f66" +source = "git+https://github.com/fermyon/cloud-openapi?rev=236278f45b5afe5d1e575b3e4882a3fb93ee41d9#236278f45b5afe5d1e575b3e4882a3fb93ee41d9" dependencies = [ "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 90766f2..6631289 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ openssl = { version = "0.10" } [workspace.dependencies] tracing = { version = "0.1", features = ["log"] } -cloud-openapi = { git = "https://github.com/fermyon/cloud-openapi", rev = "3e70369e3fc9574e262827332157da40da0a4f66" } +cloud-openapi = { git = "https://github.com/fermyon/cloud-openapi", rev = "236278f45b5afe5d1e575b3e4882a3fb93ee41d9" } [build-dependencies] vergen = { version = "^8.2.1", default-features = false, features = [ diff --git a/crates/cloud/src/client.rs b/crates/cloud/src/client.rs index e7e996e..826635c 100644 --- a/crates/cloud/src/client.rs +++ b/crates/cloud/src/client.rs @@ -7,7 +7,8 @@ use cloud_openapi::{ auth_tokens_api::api_auth_tokens_refresh_post, channels_api::{ api_channels_get, api_channels_id_delete, api_channels_id_get, - api_channels_id_logs_get, api_channels_post, ApiChannelsIdPatchError, + api_channels_id_logs_get, api_channels_id_logs_raw_get, api_channels_post, + ApiChannelsIdPatchError, }, configuration::{ApiKey, Configuration}, device_codes_api::api_device_codes_post, @@ -28,8 +29,9 @@ use cloud_openapi::{ CreateAppCommand, CreateChannelCommand, CreateDeviceCodeCommand, CreateKeyValuePairCommand, CreateSqlDatabaseCommand, CreateVariablePairCommand, Database, DeleteSqlDatabaseCommand, DeleteVariablePairCommand, DeviceCodeItem, EnvironmentVariableItem, - ExecuteSqlStatementCommand, GetChannelLogsVm, GetSqlDatabasesQuery, GetVariablesQuery, - RefreshTokenCommand, RegisterRevisionCommand, ResourceLabel, RevisionItemPage, TokenInfo, + ExecuteSqlStatementCommand, GetChannelLogsVm, GetChannelRawLogsVm, GetSqlDatabasesQuery, + GetVariablesQuery, RefreshTokenCommand, RegisterRevisionCommand, ResourceLabel, + RevisionItemPage, TokenInfo, }, }; use reqwest::header; @@ -303,7 +305,18 @@ impl CloudClientInterface for Client { } async fn channel_logs(&self, id: String) -> Result { - api_channels_id_logs_get(&self.configuration, &id, None, None) + api_channels_id_logs_get(&self.configuration, &id, None, None, None) + .await + .map_err(format_response_error) + } + + async fn channel_logs_raw( + &self, + id: String, + max_lines: Option, + since: Option, + ) -> Result { + api_channels_id_logs_raw_get(&self.configuration, &id, max_lines, since.as_deref(), None) .await .map_err(format_response_error) } diff --git a/crates/cloud/src/client_interface.rs b/crates/cloud/src/client_interface.rs index 2152a21..0ad28be 100644 --- a/crates/cloud/src/client_interface.rs +++ b/crates/cloud/src/client_interface.rs @@ -2,8 +2,8 @@ use anyhow::Result; use async_trait::async_trait; use cloud_openapi::models::{ AppItem, AppItemPage, ChannelItem, ChannelItemPage, ChannelRevisionSelectionStrategy, Database, - DeviceCodeItem, EnvironmentVariableItem, GetChannelLogsVm, ResourceLabel, RevisionItemPage, - TokenInfo, + DeviceCodeItem, EnvironmentVariableItem, GetChannelLogsVm, GetChannelRawLogsVm, ResourceLabel, + RevisionItemPage, TokenInfo, }; use std::string::String; @@ -55,6 +55,13 @@ pub trait CloudClientInterface: Send + Sync { async fn channel_logs(&self, id: String) -> Result; + async fn channel_logs_raw( + &self, + id: String, + max_lines: Option, + since: Option, + ) -> Result; + async fn add_revision( &self, app_storage_id: String, diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index f42bd5b..c8bef71 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -37,7 +37,7 @@ use crate::{ use super::sqlite::database_has_link; -const SPIN_DEPLOY_CHANNEL_NAME: &str = "spin-deploy"; +pub const SPIN_DEPLOY_CHANNEL_NAME: &str = "spin-deploy"; const SPIN_DEFAULT_KV_STORE: &str = "default"; /// Package and upload an application to the Fermyon Cloud. diff --git a/src/commands/logs.rs b/src/commands/logs.rs new file mode 100644 index 0000000..726c73e --- /dev/null +++ b/src/commands/logs.rs @@ -0,0 +1,168 @@ +use std::ops::Sub; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use cloud_openapi::models::Entry; + +use cloud::{client::Client as CloudClient, CloudClientExt, CloudClientInterface}; + +use crate::commands::create_cloud_client; +use crate::commands::deploy::SPIN_DEPLOY_CHANNEL_NAME; +use crate::opts::*; +use clap::Parser; +use uuid::Uuid; + +/// Fetch and tail logs of an application deployed to the Fermyon Cloud. +#[derive(Parser, Debug)] +#[clap(about = "fetch logs for an app from Fermyon Cloud")] +pub struct LogsCommand { + /// Find app to fetch logs for in the Fermyon instance saved under the specified name. + /// If omitted, Spin looks for app in default unnamed instance. + #[clap( + name = "environment-name", + long = "environment-name", + env = DEPLOYMENT_ENV_NAME_ENV + )] + pub deployment_env_id: Option, + + /// App name + #[clap(name = "app-name", long = "app-name")] + pub app_name: String, + + /// Follow logs output + #[clap(name = "follow", long = "follow")] + pub follow: bool, + + /// Number of lines to show from the end of the logs + #[clap(name = "tail", long = "tail", default_value = "10")] + pub max_lines: i32, + + /// interval in secs to refresh logs from cloud + #[clap(parse(try_from_str = parse_interval), name="interval", long="interval", default_value = "2")] + pub interval_secs: u64, + + /// fetch logs since + #[clap(parse(try_from_str = parse_duration), name="since", long="since", default_value = "7d")] + pub since: std::time::Duration, +} + +impl LogsCommand { + pub async fn run(self) -> Result<()> { + let client = create_cloud_client(self.deployment_env_id.as_deref()).await?; + let app_name: String = self.app_name.clone(); + + self.logs(client, app_name.as_str()) + .await + .map_err(|e| anyhow!("{:?}\n\nLearn more at {}", e, "DEVELOPER_CLOUD_FAQ")) + } + + async fn logs(self, client: CloudClient, app_name: &str) -> Result<()> { + let app_id = match client + .get_app_id(app_name) + .await + .map_err(|_e| anyhow!("app with name {:?} not found", app_name))? + { + Some(x) => x, + None => return Err(anyhow!("app with name {:?} not found", app_name)), + }; + + let channel_id = client + .get_channel_id(app_id, SPIN_DEPLOY_CHANNEL_NAME) + .await?; + + fetch_logs_and_print_loop( + &client, + channel_id, + self.follow, + self.interval_secs, + Some(self.max_lines), + self.since, + ) + .await?; + + Ok(()) + } +} + +async fn fetch_logs_and_print_loop( + client: &CloudClient, + channel_id: Uuid, + follow: bool, + interval: u64, + max_lines: Option, + since: Duration, +) -> Result<()> { + let mut new_since = Utc::now().sub(since).to_rfc3339(); + new_since = + fetch_logs_and_print_once(client, channel_id, max_lines, new_since.to_owned()).await?; + + if !follow { + return Ok(()); + } + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + new_since = + fetch_logs_and_print_once(client, channel_id, None, new_since.to_owned()).await?; + } +} + +async fn fetch_logs_and_print_once( + client: &CloudClient, + channel_id: Uuid, + max_lines: Option, + since: String, +) -> Result { + let entries = client + .channel_logs_raw(channel_id.to_string(), max_lines, Some(since.to_string())) + .await? + .entries; + + if entries.is_empty() { + return Ok(since.to_owned()); + } + + Ok(print_lastn_logs(&entries).to_owned()) +} + +fn print_lastn_logs(entries: &[Entry]) -> &str { + let mut new_since: &str = ""; + for entry in entries.iter().rev() { + for line in entry.log_lines.as_ref().unwrap() { + println!("{}", line.line.as_ref().unwrap()); + new_since = line.time.as_ref().unwrap().as_str() + } + } + + new_since +} + +fn parse_duration(arg: &str) -> Result { + let duration = if let Some(parg) = arg.strip_suffix('s') { + let value = parg.parse()?; + std::time::Duration::from_secs(value) + } else if let Some(parg) = arg.strip_suffix('m') { + let value: u64 = parg.parse()?; + std::time::Duration::from_secs(value * 60) + } else if let Some(parg) = arg.strip_suffix('h') { + let value: u64 = parg.parse()?; + std::time::Duration::from_secs(value * 60 * 60) + } else if let Some(parg) = arg.strip_suffix('d') { + let value: u64 = parg.parse()?; + std::time::Duration::from_secs(value * 24 * 60 * 60) + } else { + return Err(anyhow!("invalid duration")); + }; + + Ok(duration) +} + +fn parse_interval(arg: &str) -> Result { + let interval = arg.parse()?; + if interval < 2 { + return Err(anyhow!("interval cannot be less than 2 seconds")); + } + + Ok(interval) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fae1c36..04b4c56 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod deploy; pub mod link; pub mod login; +pub mod logs; pub mod sqlite; pub mod variables; diff --git a/src/main.rs b/src/main.rs index 98b49de..ef4ca4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use commands::{ deploy::DeployCommand, link::{LinkCommand, UnlinkCommand}, login::LoginCommand, + logs::LogsCommand, sqlite::SqliteCommand, variables::VariablesCommand, }; @@ -35,6 +36,8 @@ enum CloudCli { Deploy(DeployCommand), /// Login to Fermyon Cloud Login(LoginCommand), + /// Fetches app logs from Fermyon Cloud + Logs(LogsCommand), /// Manage Spin application variables #[clap(subcommand, alias = "vars")] Variables(VariablesCommand), @@ -61,6 +64,7 @@ async fn main() -> Result<(), Error> { CloudCli::Apps(cmd) => cmd.run().await, CloudCli::Deploy(cmd) => cmd.run().await, CloudCli::Login(cmd) => cmd.run().await, + CloudCli::Logs(cmd) => cmd.run().await, CloudCli::Variables(cmd) => cmd.run().await, CloudCli::Sqlite(cmd) => cmd.run().await, CloudCli::Link(cmd) => cmd.run().await,