Skip to content

Commit

Permalink
implement logs command
Browse files Browse the repository at this point in the history
Signed-off-by: Rajat Jindal <[email protected]>
  • Loading branch information
rajatjindal committed Oct 31, 2023
1 parent c158469 commit 2415802
Show file tree
Hide file tree
Showing 8 changed files with 333 additions and 79 deletions.
156 changes: 95 additions & 61 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,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 = "ce1e916110b9a9e59a1171ac364f0b6e23908428" }

[build-dependencies]
vergen = { version = "^8.2.1", default-features = false, features = [
Expand Down
38 changes: 27 additions & 11 deletions crates/cloud/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ use async_trait::async_trait;
use cloud_openapi::{
apis::{
self,
apps_api::{api_apps_get, api_apps_id_delete, api_apps_id_get, api_apps_post},
apps_api::{
api_apps_get, api_apps_id_delete, api_apps_id_get, api_apps_id_logs_get,
api_apps_id_logs_raw_get, api_apps_post,
},
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_get, api_channels_id_delete, api_channels_id_get, api_channels_post,
ApiChannelsIdPatchError,
},
configuration::{ApiKey, Configuration},
device_codes_api::api_device_codes_post,
Expand All @@ -28,8 +31,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, GetAppLogsVm, GetAppRawLogsVm, GetSqlDatabasesQuery,
GetVariablesQuery, RefreshTokenCommand, RegisterRevisionCommand, ResourceLabel,
RevisionItemPage, TokenInfo,
},
};
use reqwest::header;
Expand Down Expand Up @@ -173,11 +177,29 @@ impl CloudClientInterface for Client {
None,
None,
None,
None,
)
.await
.map_err(format_response_error)
}

async fn app_logs(&self, id: String) -> Result<GetAppLogsVm> {
api_apps_id_logs_get(&self.configuration, &id, None, None, None)
.await
.map_err(format_response_error)
}

async fn app_logs_raw(
&self,
id: String,
max_lines: Option<i32>,
since: Option<String>,
) -> Result<GetAppRawLogsVm> {
api_apps_id_logs_raw_get(&self.configuration, &id, max_lines, since.as_deref(), None)
.await
.map_err(format_response_error)
}

async fn get_channel_by_id(&self, id: &str) -> Result<ChannelItem> {
api_channels_id_get(&self.configuration, id, None)
.await
Expand Down Expand Up @@ -302,12 +324,6 @@ impl CloudClientInterface for Client {
.map_err(format_response_error)
}

async fn channel_logs(&self, id: String) -> Result<GetChannelLogsVm> {
api_channels_id_logs_get(&self.configuration, &id, None, None)
.await
.map_err(format_response_error)
}

async fn add_revision(
&self,
app_storage_id: String,
Expand Down
15 changes: 11 additions & 4 deletions crates/cloud/src/client_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, GetAppLogsVm, GetAppRawLogsVm, ResourceLabel,
RevisionItemPage, TokenInfo,
};

use std::string::String;
Expand All @@ -26,6 +26,15 @@ pub trait CloudClientInterface: Send + Sync {

async fn list_apps(&self, page_size: i32, page_index: Option<i32>) -> Result<AppItemPage>;

async fn app_logs(&self, id: String) -> Result<GetAppLogsVm>;

async fn app_logs_raw(
&self,
id: String,
max_lines: Option<i32>,
since: Option<String>,
) -> Result<GetAppRawLogsVm>;

async fn get_channel_by_id(&self, id: &str) -> Result<ChannelItem>;

async fn list_channels(&self) -> Result<ChannelItemPage>;
Expand Down Expand Up @@ -53,8 +62,6 @@ pub trait CloudClientInterface: Send + Sync {

async fn remove_channel(&self, id: String) -> Result<()>;

async fn channel_logs(&self, id: String) -> Result<GetChannelLogsVm>;

async fn add_revision(
&self,
app_storage_id: String,
Expand Down
4 changes: 2 additions & 2 deletions src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ use crate::{
use super::sqlite::database_has_link;

const SPIN_DEPLOY_CHANNEL_NAME: &str = "spin-deploy";
pub const DEVELOPER_CLOUD_FAQ: &str = "https://developer.fermyon.com/cloud/faq";

const SPIN_DEFAULT_KV_STORE: &str = "default";

/// Package and upload an application to the Fermyon Cloud.
Expand Down Expand Up @@ -118,8 +120,6 @@ impl DeployCommand {

let login_connection = login_connection(self.deployment_env_id.as_deref()).await?;

const DEVELOPER_CLOUD_FAQ: &str = "https://developer.fermyon.com/cloud/faq";

self.deploy_cloud(login_connection)
.await
.map_err(|e| anyhow!("{:?}\n\nLearn more at {}", e, DEVELOPER_CLOUD_FAQ))
Expand Down
192 changes: 192 additions & 0 deletions src/commands/logs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
use std::ops::Sub;
use std::time::Duration;

use anyhow::{bail, Context, Result};
use chrono::Utc;
use cloud::{CloudClientExt, CloudClientInterface};
use cloud_openapi::models::Entry;
use std::option::Option;

use crate::commands::create_cloud_client;
use crate::opts::*;
use clap::Parser;
use uuid::Uuid;

/// fetch logs for an app from Fermyon Cloud
#[derive(Parser, Debug)]
pub struct LogsCommand {
/// Use 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<String>,

/// App name
pub app: 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 seconds to refresh logs from cloud
#[clap(parse(try_from_str = parse_interval), name="interval", long="interval", default_value = "2")]
pub interval_secs: std::time::Duration,

/// Only return logs newer than a relative duration. The duration format is a number
/// and a unit, where the unit is 's' for seconds, 'm' for minutes, 'h' for hours
/// or 'd' for days (e.g. "30m" for 30 minutes ago). The default it 7 days.
#[clap(parse(try_from_str = parse_duration), name="since", long="since", default_value = "7d")]
pub since: std::time::Duration,

/// Show timestamps
#[clap(
name = "show-timestamps",
long = "show-timestamps",
default_value = "true",
action = clap::ArgAction::Set
)]
pub show_timestamp: bool,
}

impl LogsCommand {
pub async fn run(self) -> Result<()> {
let client = create_cloud_client(self.deployment_env_id.as_deref()).await?;
self.logs(&client).await
}

async fn logs(self, client: &impl CloudClientInterface) -> Result<()> {
let app_id = client
.get_app_id(&self.app)
.await
.with_context(|| format!("failed to find app with name {:?}", &self.app))?
.with_context(|| format!("app with name {:?} not found", &self.app))?;

fetch_logs_and_print_loop(
client,
app_id,
self.follow,
self.interval_secs,
self.max_lines,
self.since,
self.show_timestamp,
)
.await?;

Ok(())
}
}

async fn fetch_logs_and_print_loop(
client: &impl CloudClientInterface,
app_id: Uuid,
follow: bool,
interval: Duration,
max_lines: i32,
since: Duration,
show_timestamp: bool,
) -> Result<()> {
let mut curr_since = Utc::now().sub(since).to_rfc3339();
curr_since =
fetch_logs_and_print_once(client, app_id, Some(max_lines), curr_since, show_timestamp)
.await?;

if !follow {
return Ok(());
}

loop {
tokio::time::sleep(interval).await;
curr_since =
fetch_logs_and_print_once(client, app_id, None, curr_since, show_timestamp).await?;
}
}

async fn fetch_logs_and_print_once(
client: &impl CloudClientInterface,
app_id: Uuid,
max_lines: Option<i32>,
since: String,
show_timestamp: bool,
) -> Result<String> {
let entries = client
.app_logs_raw(app_id.to_string(), max_lines, Some(since.to_string()))
.await?
.entries;

if entries.is_empty() {
return Ok(since.to_owned());
}

let updated_since = print_logs(&entries, show_timestamp);
if let Some(u) = updated_since {
return Ok(u.to_owned());
}

Ok(since)
}

fn print_logs(entries: &[Entry], show_timestamp: bool) -> Option<&str> {
let mut since = None;
for entry in entries.iter().rev() {
let Some(log_lines) = entry.log_lines.as_ref() else {
continue;
};

for log_entry in log_lines {
let Some(log) = log_entry.line.as_ref() else {
continue;
};

match &log_entry.time {
Some(time) => {
if show_timestamp {
println!("[{time}] {log}");
} else {
println!("{log}");
}

since = Some(time.as_str());
}
None => (),
}
}
}

since
}

fn parse_duration(arg: &str) -> anyhow::Result<std::time::Duration> {
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 {
bail!(r#"since must be a number followed by an allowed unit ("300s", "5m", "4h" or "1d")"#);
};

Ok(duration)
}

fn parse_interval(arg: &str) -> anyhow::Result<std::time::Duration> {
let value = arg.parse()?;
if value < 2 {
bail!("interval cannot be less than 2 seconds")
}

Ok(std::time::Duration::from_secs(value))
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use commands::{
deploy::DeployCommand,
link::{LinkCommand, UnlinkCommand},
login::LoginCommand,
logs::LogsCommand,
sqlite::SqliteCommand,
variables::VariablesCommand,
};
Expand All @@ -35,6 +36,8 @@ enum CloudCli {
Deploy(DeployCommand),
/// Login to Fermyon Cloud
Login(LoginCommand),
/// Fetch logs for an app from Fermyon Cloud
Logs(LogsCommand),
/// Manage Spin application variables
#[clap(subcommand, alias = "vars")]
Variables(VariablesCommand),
Expand All @@ -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,
Expand Down

0 comments on commit 2415802

Please sign in to comment.