From 1e653e570b743855ce73340a595145540637ca37 Mon Sep 17 00:00:00 2001 From: Adrian Benavides Date: Fri, 5 Jul 2024 10:51:29 +0200 Subject: [PATCH] feat(rust): integrate space's subscription data in command --- .../ockam/ockam_api/src/cli_state/spaces.rs | 28 ++- .../storage/spaces_repository_sql.rs | 130 ++++++++++-- .../ockam_api/src/cloud/share/invitation.rs | 21 +- .../rust/ockam/ockam_api/src/cloud/space.rs | 118 ++++++++--- .../ockam/ockam_api/src/cloud/subscription.rs | 196 ++++++++++++++---- .../rust/ockam/ockam_api/src/date.rs | 21 ++ .../rust/ockam/ockam_api/src/lib.rs | 1 + .../ockam_api/src/nodes/models/services.rs | 6 - .../ockam/ockam_api/src/schema/schema.cddl | 12 +- .../rust/ockam/ockam_api/src/ui/output/mod.rs | 66 +++++- .../ockam/ockam_api/src/ui/output/utils.rs | 12 +- .../ockam/ockam_command/src/enroll/command.rs | 25 +-- .../ockam_command/src/project_member/mod.rs | 26 +-- .../rust/ockam/ockam_command/src/reset.rs | 2 +- .../ockam/ockam_command/src/space/create.rs | 60 +++--- .../ockam/ockam_command/src/space/delete.rs | 42 ++-- .../ockam/ockam_command/src/space/list.rs | 46 ++-- .../rust/ockam/ockam_command/src/space/mod.rs | 2 +- .../ockam/ockam_command/src/space/show.rs | 46 ++-- .../rust/ockam/ockam_command/src/status.rs | 68 ++++-- .../ockam/ockam_command/src/subscription.rs | 4 +- .../rust/ockam/ockam_command/src/version.rs | 42 +++- .../20240703100000_add_subscriptions.sql | 9 + .../20240703100000_add_subscriptions.sql | 9 + 24 files changed, 685 insertions(+), 307 deletions(-) create mode 100644 implementations/rust/ockam/ockam_api/src/date.rs create mode 100644 implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/postgres/20240703100000_add_subscriptions.sql create mode 100644 implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/sqlite/20240703100000_add_subscriptions.sql diff --git a/implementations/rust/ockam/ockam_api/src/cli_state/spaces.rs b/implementations/rust/ockam/ockam_api/src/cli_state/spaces.rs index 659cd98bc3a..0b5a7913082 100644 --- a/implementations/rust/ockam/ockam_api/src/cli_state/spaces.rs +++ b/implementations/rust/ockam/ockam_api/src/cli_state/spaces.rs @@ -3,6 +3,7 @@ use ockam_core::Error; use crate::cli_state::CliState; use crate::cloud::space::Space; +use crate::cloud::subscription::Subscription; use super::Result; @@ -13,20 +14,22 @@ impl CliState { space_id: &str, space_name: &str, users: Vec<&str>, + subscription: Option<&Subscription>, ) -> Result { - let repository = self.spaces_repository(); + let space_repository = self.spaces_repository(); let space = Space { id: space_id.to_string(), name: space_name.to_string(), users: users.iter().map(|u| u.to_string()).collect(), + subscription: subscription.cloned(), }; - repository.store_space(&space).await?; + space_repository.store_space(&space).await?; // If there is no previous default space set this space as the default - let default_space = repository.get_default_space().await?; + let default_space = space_repository.get_default_space().await?; if default_space.is_none() { - repository.set_default_space(&space.id).await? + space_repository.set_default_space(&space.id).await? }; Ok(space) @@ -96,13 +99,26 @@ mod test { // the first created space becomes the default let space1 = cli - .store_space("1", "name1", vec!["me@ockam.io", "you@ockam.io"]) + .store_space( + "1", + "name1", + vec!["me@ockam.io", "you@ockam.io"], + Some(&Subscription::new( + "name1".to_string(), + false, + None, + None, + None, + )), + ) .await?; let result = cli.get_default_space().await?; assert_eq!(result, space1); // the store method can be used to update a space - let updated_space1 = cli.store_space("1", "name1", vec!["them@ockam.io"]).await?; + let updated_space1 = cli + .store_space("1", "name1", vec!["them@ockam.io"], None) + .await?; let result = cli.get_default_space().await?; assert_eq!(result, updated_space1); diff --git a/implementations/rust/ockam/ockam_api/src/cli_state/storage/spaces_repository_sql.rs b/implementations/rust/ockam/ockam_api/src/cli_state/storage/spaces_repository_sql.rs index a1bd917b514..c380210f8a8 100644 --- a/implementations/rust/ockam/ockam_api/src/cli_state/storage/spaces_repository_sql.rs +++ b/implementations/rust/ockam/ockam_api/src/cli_state/storage/spaces_repository_sql.rs @@ -1,13 +1,12 @@ -use sqlx::any::AnyRow; -use sqlx::*; - +use super::SpacesRepository; +use crate::cloud::space::Space; +use crate::cloud::subscription::Subscription; use ockam_core::async_trait; use ockam_core::Result; -use ockam_node::database::{Boolean, FromSqlxError, SqlxDatabase, ToVoid}; - -use crate::cloud::space::Space; - -use super::SpacesRepository; +use ockam_node::database::{Boolean, FromSqlxError, Nullable, SqlxDatabase, ToVoid}; +use sqlx::any::AnyRow; +use sqlx::*; +use time::OffsetDateTime; #[derive(Clone)] pub struct SpacesSqlxDatabase { @@ -25,6 +24,15 @@ impl SpacesSqlxDatabase { pub async fn create() -> Result { Ok(Self::new(SqlxDatabase::in_memory("spaces").await?)) } + + async fn query_subscription(&self, space_id: &str) -> Result> { + let query = query_as("SELECT space_id, name, is_free_trial, marketplace, start_date, end_date FROM subscription WHERE space_id = $1").bind(space_id); + let row: Option = query + .fetch_optional(&*self.database.pool) + .await + .into_core()?; + Ok(row.map(|r| r.subscription())) + } } #[async_trait] @@ -69,6 +77,31 @@ impl SpacesRepository for SpacesSqlxDatabase { query4.execute(&mut *transaction).await.void()?; } + // store the subscription if any + if let Some(subscription) = &space.subscription { + let start_date = subscription.start_date(); + let end_date = subscription.end_date(); + let query = query( + r" + INSERT INTO subscription (space_id, name, is_free_trial, marketplace, start_date, end_date) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (space_id) + DO UPDATE SET space_id = $1, name = $2, is_free_trial = $3, marketplace = $4, start_date = $5, end_date = $6", + ) + .bind(&space.id) + .bind(&subscription.name) + .bind(subscription.is_free_trial) + .bind(&subscription.marketplace) + .bind(start_date.map(|d| d.unix_timestamp())) + .bind(end_date.map(|d| d.unix_timestamp())); + query.execute(&mut *transaction).await.void()?; + } + // remove the subscription + else { + let query = query("DELETE FROM subscription WHERE space_id = $1").bind(&space.id); + query.execute(&mut *transaction).await.void()?; + } + transaction.commit().await.void() } @@ -95,6 +128,7 @@ impl SpacesRepository for SpacesSqlxDatabase { let row: Option = query1.fetch_optional(&mut *transaction).await.into_core()?; let space = match row.map(|r| r.space()) { Some(mut space) => { + // retrieve the users let query2 = query_as("SELECT space_id, user_email FROM user_space WHERE space_id = $1") .bind(&space.id); @@ -102,6 +136,10 @@ impl SpacesRepository for SpacesSqlxDatabase { query2.fetch_all(&mut *transaction).await.into_core()?; let users = rows.into_iter().map(|r| r.user_email).collect(); space.users = users; + + // retrieve the subscription + space.subscription = self.query_subscription(&space.id).await?; + Some(space) } None => None, @@ -114,16 +152,20 @@ impl SpacesRepository for SpacesSqlxDatabase { let mut transaction = self.database.begin().await.into_core()?; let query = query_as("SELECT space_id, space_name FROM space"); - let row: Vec = query.fetch_all(&mut *transaction).await.into_core()?; + let rows: Vec = query.fetch_all(&mut *transaction).await.into_core()?; let mut spaces = vec![]; - for space_row in row { + for row in rows { let query2 = query_as("SELECT space_id, user_email FROM user_space WHERE space_id = $1") - .bind(&space_row.space_id); + .bind(&row.space_id); let rows: Vec = query2.fetch_all(&mut *transaction).await.into_core()?; let users = rows.into_iter().map(|r| r.user_email).collect(); - spaces.push(space_row.space_with_user_emails(users)) + let subscription = self.query_subscription(&row.space_id).await?; + let mut space = row.space(); + space.users = users; + space.subscription = subscription; + spaces.push(space); } transaction.commit().await.void()?; @@ -169,6 +211,9 @@ impl SpacesRepository for SpacesSqlxDatabase { let query2 = query("DELETE FROM user_space WHERE space_id = $1").bind(space_id); query2.execute(&mut *transaction).await.void()?; + let query3 = query("DELETE FROM subscription WHERE space_id = $1").bind(space_id); + query3.execute(&mut *transaction).await.void()?; + transaction.commit().await.void() } } @@ -184,14 +229,11 @@ struct SpaceRow { impl SpaceRow { pub(crate) fn space(&self) -> Space { - self.space_with_user_emails(vec![]) - } - - pub(crate) fn space_with_user_emails(&self, user_emails: Vec) -> Space { Space { id: self.space_id.clone(), name: self.space_name.clone(), - users: user_emails, + users: vec![], + subscription: None, } } } @@ -204,22 +246,52 @@ struct UserSpaceRow { user_email: String, } +/// Low-level representation of a row in the subscription table +#[derive(sqlx::FromRow)] +pub(super) struct SubscriptionRow { + #[allow(unused)] + space_id: String, + name: String, + is_free_trial: Boolean, + marketplace: Nullable, + start_date: Nullable, + end_date: Nullable, +} + +impl SubscriptionRow { + pub(crate) fn subscription(&self) -> Subscription { + Subscription::new( + self.name.clone(), + self.is_free_trial.to_bool(), + self.marketplace.to_option(), + self.start_date + .to_option() + .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok()), + self.end_date + .to_option() + .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok()), + ) + } +} + #[cfg(test)] mod test { use super::*; use ockam_node::database::with_dbs; - use std::sync::Arc; + use std::ops::Add; + use time::ext::NumericalDuration; #[tokio::test] async fn test_repository() -> Result<()> { with_dbs(|db| async move { - let repository: Arc = Arc::new(SpacesSqlxDatabase::new(db)); + let repository = SpacesSqlxDatabase::new(db); // create and store 2 spaces let space1 = Space { id: "1".to_string(), name: "name1".to_string(), users: vec!["me@ockam.io".to_string(), "you@ockam.io".to_string()], + subscription: None, }; let mut space2 = Space { id: "2".to_string(), @@ -229,11 +301,24 @@ mod test { "him@ockam.io".to_string(), "her@ockam.io".to_string(), ], + subscription: Some(Subscription::new( + "premium".to_string(), + false, + Some("aws".to_string()), + Some(OffsetDateTime::now_utc()), + Some(OffsetDateTime::now_utc().add(2.days())), + )), }; repository.store_space(&space1).await?; repository.store_space(&space2).await?; + // subscription is stored + let result = repository.query_subscription("1").await?; + assert_eq!(result, None); + let result = repository.query_subscription("2").await?; + assert_eq!(result, Some(space2.subscription.clone().unwrap())); + // retrieve them as a vector or by name let result = repository.get_spaces().await?; assert_eq!(result, vec![space1.clone(), space2.clone()]); @@ -263,6 +348,13 @@ mod test { let result = repository.get_spaces().await?; assert_eq!(result, vec![space1.clone()]); + + // subscription is deleted + let result = repository.query_subscription("1").await?; + assert_eq!(result, None); + let result = repository.query_subscription("2").await?; + assert_eq!(result, None); + Ok(()) }) .await diff --git a/implementations/rust/ockam/ockam_api/src/cloud/share/invitation.rs b/implementations/rust/ockam/ockam_api/src/cloud/share/invitation.rs index a759143996f..936ab810262 100644 --- a/implementations/rust/ockam/ockam_api/src/cloud/share/invitation.rs +++ b/implementations/rust/ockam/ockam_api/src/cloud/share/invitation.rs @@ -1,14 +1,13 @@ use crate::address::extract_address_value; use crate::cli_state::EnrollmentTicket; use crate::cloud::email_address::EmailAddress; +use crate::date::is_expired; use crate::error::ApiError; use crate::output::Output; use minicbor::{CborLen, Decode, Encode}; use ockam::identity::Identifier; use serde::{Deserialize, Serialize}; use std::{fmt::Display, str::FromStr}; -use time::format_description::well_known::iso8601::Iso8601; -use time::OffsetDateTime; #[derive(Clone, Debug, Eq, PartialEq, Decode, Encode, CborLen, Deserialize, Serialize)] #[cbor(index_only)] @@ -149,20 +148,6 @@ impl Output for SentInvitation { } } -/// Check if a string that represents an Iso8601 date is expired, using the `time` crate -fn is_expired(date: &str) -> ockam_core::Result { - // Add the Z timezone to the date, as the `time` crate requires it - let date = if date.ends_with('Z') { - date.to_string() - } else { - format!("{}Z", date) - }; - let now = OffsetDateTime::now_utc(); - let date = OffsetDateTime::parse(&date, &Iso8601::DEFAULT) - .map_err(|e| ApiError::core(e.to_string()))?; - Ok(date < now) -} - #[derive(Clone, Debug, Encode, Decode, CborLen, Deserialize, Serialize, PartialEq)] #[cbor(map)] #[rustfmt::skip] @@ -192,7 +177,9 @@ impl ServiceAccessDetails { #[cfg(test)] mod test { - use super::*; + use crate::date::is_expired; + use time::format_description::well_known::Iso8601; + use time::OffsetDateTime; #[test] fn test_is_expired() { diff --git a/implementations/rust/ockam/ockam_api/src/cloud/space.rs b/implementations/rust/ockam/ockam_api/src/cloud/space.rs index 28d32677773..e3ec486963f 100644 --- a/implementations/rust/ockam/ockam_api/src/cloud/space.rs +++ b/implementations/rust/ockam/ockam_api/src/cloud/space.rs @@ -1,18 +1,20 @@ -use colorful::Colorful; use miette::IntoDiagnostic; use minicbor::{CborLen, Decode, Encode}; use serde::Serialize; -use std::fmt::Write; +use std::fmt::{Display, Formatter, Write}; use ockam_core::api::Request; use ockam_core::async_trait; use ockam_node::Context; use crate::cloud::project::{Project, ProjectsOrchestratorApi}; +use crate::cloud::subscription::Subscription; use crate::cloud::{ControllerClient, HasSecureClient}; -use crate::colors::OckamColor; +use crate::colors::{color_primary, color_uri, color_warn}; +use crate::fmt_log; use crate::nodes::InMemoryNode; use crate::output::{comma_separated, Output}; +use crate::terminal::fmt; const TARGET: &str = "ockam_api::cloud::space"; @@ -23,6 +25,7 @@ pub struct Space { #[n(1)] pub id: String, #[n(2)] pub name: String, #[n(3)] pub users: Vec, + #[n(4)] pub subscription: Option, } impl Space { @@ -33,37 +36,92 @@ impl Space { pub fn space_name(&self) -> String { self.name.clone() } -} -impl Output for Space { - fn item(&self) -> crate::Result { - let mut w = String::new(); - write!(w, "Space")?; - write!(w, "\n Id: {}", self.id)?; - write!(w, "\n Name: {}", self.name)?; - write!(w, "\n Users: {}", comma_separated(&self.users))?; - Ok(w) + pub fn has_subscription(&self) -> bool { + self.subscription.is_some() } - fn as_list_item(&self) -> crate::Result { - let mut output = String::new(); - writeln!( - output, - "Space {}", - self.name - .to_string() - .color(OckamColor::PrimaryResource.color()) - )?; + pub fn is_in_free_trial_subscription(&self) -> bool { + self.subscription.is_none() + || self + .subscription + .as_ref() + .map(|s| s.is_free_trial) + .unwrap_or_default() + } + + pub fn subscription_status_message(&self, space_is_new: bool) -> crate::Result { + let mut f = String::new(); + if let Some(subscription) = &self.subscription { + writeln!( + f, + "{}", + fmt_log!( + "This Space has a {} Subscription attached to it.", + color_primary(&subscription.name) + ) + )?; + if subscription.is_free_trial { + if space_is_new { + writeln!(f)?; + writeln!(f, "{}", fmt_log!("As a courtesy, we created a temporary Space for you, so you can continue to build.\n"))?; + writeln!( + f, + "{}", + fmt_log!( + "Please subscribe to an Ockam plan within two weeks {}", + color_uri("https://www.ockam.io/pricing") + ) + )?; + writeln!(f, "{}", fmt_log!("{}", color_warn("If you don't subscribe in that time, your Space and all associated Projects will be permanently deleted.")))?; + } else if let (Some(start_date), Some(end_date)) = + (&subscription.start_date(), &subscription.end_date()) + { + writeln!(f)?; + writeln!( + f, + "{}", + fmt_log!( + "Your free trial started on {} and will end on {}.\n", + start_date, + end_date + ) + )?; + writeln!(f, "{}", fmt_log!("Please subscribe to an Ockam plan before the trial ends to avoid any service interruptions {}", color_uri("https://www.ockam.io/pricing")))?; + writeln!(f, "{}", fmt_log!("{}", color_warn("If you don't subscribe in that time, your Space and all associated Projects will be permanently deleted.")))?; + } + } + } else { + writeln!( + f, + "{}", + fmt_log!("This Space does not have a Subscription attached to it.") + )?; + } + Ok(f) + } +} + +impl Display for Space { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{}", color_primary(&self.name))?; + writeln!(f, "{}Id: {}", fmt::INDENTATION, color_primary(&self.id))?; writeln!( - output, - "Id {}", - self.id - .to_string() - .color(OckamColor::PrimaryResource.color()) + f, + "{}Users: {}", + fmt::INDENTATION, + comma_separated(&self.users) )?; - write!(output, "{}", comma_separated(&self.users))?; + if let Some(subscription) = &self.subscription { + write!(f, "{}", subscription.iter_output().indent())?; + } + Ok(()) + } +} - Ok(output) +impl Output for Space { + fn item(&self) -> crate::Result { + Ok(self.padded_display()) } } @@ -118,6 +176,7 @@ impl Spaces for InMemoryNode { &space.id, &space.name, space.users.iter().map(|u| u.as_ref()).collect(), + space.subscription.as_ref(), ) .await?; Ok(space) @@ -132,6 +191,7 @@ impl Spaces for InMemoryNode { &space.id, &space.name, space.users.iter().map(|u| u.as_ref()).collect(), + space.subscription.as_ref(), ) .await?; Ok(space) @@ -189,6 +249,7 @@ impl Spaces for InMemoryNode { &space.id, &space.name, space.users.iter().map(|u| u.as_ref()).collect(), + space.subscription.as_ref(), ) .await?; @@ -281,6 +342,7 @@ pub mod tests { id: String::arbitrary(g), name: String::arbitrary(g), users: vec![String::arbitrary(g), String::arbitrary(g)], + subscription: bool::arbitrary(g).then(|| Subscription::arbitrary(g)), } } } diff --git a/implementations/rust/ockam/ockam_api/src/cloud/subscription.rs b/implementations/rust/ockam/ockam_api/src/cloud/subscription.rs index 4c7618e30a3..e2eff71ca4f 100644 --- a/implementations/rust/ockam/ockam_api/src/cloud/subscription.rs +++ b/implementations/rust/ockam/ockam_api/src/cloud/subscription.rs @@ -1,10 +1,14 @@ -use minicbor::{CborLen, Decode, Encode}; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; - use crate::cloud::{ControllerClient, HasSecureClient}; use crate::output::Output; +use minicbor::{CborLen, Decode, Encode}; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter, Write}; +use time::format_description::well_known::Iso8601; +use time::OffsetDateTime; +use crate::colors::color_primary; +use crate::date::parse_date; +use crate::terminal::fmt; use ockam_core::api::{Error, Reply, Request, Status}; use ockam_core::{self, async_trait, Result}; use ockam_node::Context; @@ -50,14 +54,119 @@ impl ActivateSubscription { } } -#[derive(Encode, Decode, CborLen, Serialize, Deserialize, Debug)] -#[cfg_attr(test, derive(Clone))] +#[derive(Encode, Decode, CborLen, Serialize, Deserialize, Clone, Debug, Eq)] #[cbor(map)] pub struct Subscription { + #[n(1)] + pub name: String, + #[n(2)] + pub is_free_trial: bool, + #[n(3)] + pub marketplace: Option, + #[n(4)] + start_date: Option, + #[n(5)] + end_date: Option, +} + +impl PartialEq for Subscription { + fn eq(&self, other: &Self) -> bool { + // Compare the dates using as unix timestamps, using a tolerance of 1 second + let start_date_eq = match (self.start_date(), other.start_date()) { + (Some(start_date), Some(other_start_date)) => { + let start_date = start_date.unix_timestamp(); + let other_start_date = other_start_date.unix_timestamp(); + (start_date - other_start_date).abs() <= 1 + } + (None, None) => true, + _ => false, + }; + self.name == other.name + && self.is_free_trial == other.is_free_trial + && self.marketplace == other.marketplace + && start_date_eq + } +} + +impl Subscription { + pub fn new( + name: String, + is_free_trial: bool, + marketplace: Option, + start_date: Option, + end_date: Option, + ) -> Self { + Self { + name, + is_free_trial, + marketplace, + start_date: start_date.and_then(|date| date.format(&Iso8601::DEFAULT).ok()), + end_date: end_date.and_then(|date| date.format(&Iso8601::DEFAULT).ok()), + } + } + + pub fn end_date(&self) -> Option { + self.end_date + .as_ref() + .and_then(|date| parse_date(date).ok()) + } + + pub fn start_date(&self) -> Option { + self.start_date + .as_ref() + .and_then(|date| parse_date(date).ok()) + } +} + +impl Display for Subscription { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Subscription: {}", color_primary(&self.name))?; + if self.is_free_trial { + writeln!(f, " (free trial)")?; + } else { + writeln!(f)?; + } + + if let (Some(start_date), Some(end_date)) = (self.start_date(), self.end_date()) { + writeln!( + f, + "{}Started at {}, expires at {}", + fmt::INDENTATION, + color_primary(start_date.to_string()), + color_primary(end_date.to_string()), + )?; + } + + if let Some(marketplace) = &self.marketplace { + writeln!( + f, + "{}Marketplace: {}", + fmt::INDENTATION, + color_primary(marketplace) + )?; + } + + Ok(()) + } +} + +impl Output for Subscription { + fn item(&self) -> crate::Result { + Ok(self.padded_display()) + } +} + +/// This struct is now deprecated and used only in the legacy [Subscriptions] API endpoints. +/// The commands using this API were already removed, but the Controller still supports it. +/// This struct along with the [Subscriptions] trait can be removed once the Controller stops +/// supporting the legacy API. +#[derive(Encode, Decode, CborLen, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cbor(map)] +pub struct SubscriptionLegacy { #[n(1)] pub id: String, #[n(2)] - marketplace: String, + pub marketplace: String, #[n(3)] pub status: String, #[n(4)] @@ -70,20 +179,20 @@ pub struct Subscription { pub space_id: Option, } -impl Output for Subscription { +impl Output for SubscriptionLegacy { fn item(&self) -> crate::Result { let mut w = String::new(); - write!(w, "Subscription")?; - write!(w, "\n Id: {}", self.id)?; - write!(w, "\n Status: {}", self.status)?; + write!(w, "{}Id: {}", fmt::PADDING, self.id)?; + write!(w, "{}Status: {}", fmt::PADDING, self.status)?; write!( w, - "\n Space id: {}", + "{}Space id: {}", + fmt::PADDING, self.space_id.clone().unwrap_or("N/A".to_string()) )?; - write!(w, "\n Entitlements: {}", self.entitlements)?; - write!(w, "\n Metadata: {}", self.metadata)?; - write!(w, "\n Contact info: {}", self.contact_info)?; + write!(w, "{}Entitlements: {}", fmt::PADDING, self.entitlements)?; + write!(w, "{}Metadata: {}", fmt::PADDING, self.metadata)?; + write!(w, "{}Contact info: {}", fmt::PADDING, self.contact_info)?; Ok(w) } } @@ -95,41 +204,41 @@ pub trait Subscriptions { ctx: &Context, space_id: String, subscription_data: String, - ) -> Result>; + ) -> Result>; async fn unsubscribe( &self, ctx: &Context, subscription_id: String, - ) -> Result>; + ) -> Result>; async fn update_subscription_contact_info( &self, ctx: &Context, subscription_id: String, contact_info: String, - ) -> Result>; + ) -> Result>; async fn update_subscription_space( &self, ctx: &Context, subscription_id: String, new_space_id: String, - ) -> Result>; + ) -> Result>; - async fn get_subscriptions(&self, ctx: &Context) -> Result>>; + async fn get_subscriptions(&self, ctx: &Context) -> Result>>; async fn get_subscription( &self, ctx: &Context, subscription_id: String, - ) -> Result>; + ) -> Result>; async fn get_subscription_by_space_id( &self, ctx: &Context, space_id: String, - ) -> Result>; + ) -> Result>; } #[async_trait] @@ -140,7 +249,7 @@ impl Subscriptions for ControllerClient { ctx: &Context, space_id: String, subscription_data: String, - ) -> Result> { + ) -> Result> { let req_body = ActivateSubscription::existing(space_id, subscription_data); trace!(target: TARGET, space_id = ?req_body.space_id, space_name = ?req_body.space_name, "activating subscription"); let req = Request::post("/v0/activate").body(req_body); @@ -152,7 +261,7 @@ impl Subscriptions for ControllerClient { &self, ctx: &Context, subscription_id: String, - ) -> Result> { + ) -> Result> { trace!(target: TARGET, subscription = %subscription_id, "unsubscribing"); let req = Request::put(format!("/v0/{subscription_id}/unsubscribe")); self.get_secure_client().ask(ctx, API_SERVICE, req).await @@ -164,7 +273,7 @@ impl Subscriptions for ControllerClient { ctx: &Context, subscription_id: String, contact_info: String, - ) -> Result> { + ) -> Result> { trace!(target: TARGET, subscription = %subscription_id, "updating subscription contact info"); let req = Request::put(format!("/v0/{subscription_id}/contact_info")).body(contact_info); self.get_secure_client().ask(ctx, API_SERVICE, req).await @@ -176,14 +285,14 @@ impl Subscriptions for ControllerClient { ctx: &Context, subscription_id: String, new_space_id: String, - ) -> Result> { + ) -> Result> { trace!(target: TARGET, subscription = %subscription_id, new_space_id = %new_space_id, "updating subscription space"); let req = Request::put(format!("/v0/{subscription_id}/space_id")).body(new_space_id); self.get_secure_client().ask(ctx, API_SERVICE, req).await } #[instrument(skip_all)] - async fn get_subscriptions(&self, ctx: &Context) -> Result>> { + async fn get_subscriptions(&self, ctx: &Context) -> Result>> { trace!(target: TARGET, "listing subscriptions"); let req = Request::get("/v0/"); self.get_secure_client().ask(ctx, API_SERVICE, req).await @@ -194,7 +303,7 @@ impl Subscriptions for ControllerClient { &self, ctx: &Context, subscription_id: String, - ) -> Result> { + ) -> Result> { trace!(target: TARGET, subscription = %subscription_id, "getting subscription"); let req = Request::get(format!("/v0/{subscription_id}")); self.get_secure_client().ask(ctx, API_SERVICE, req).await @@ -205,8 +314,9 @@ impl Subscriptions for ControllerClient { &self, ctx: &Context, space_id: String, - ) -> Result> { - let subscriptions: Vec = self.get_subscriptions(ctx).await?.success()?; + ) -> Result> { + let subscriptions: Vec = + self.get_subscriptions(ctx).await?.success()?; let subscription = subscriptions .into_iter() .find(|s| s.space_id == Some(space_id.clone())); @@ -222,15 +332,13 @@ impl Subscriptions for ControllerClient { #[cfg(test)] pub mod tests { - use quickcheck::{quickcheck, Arbitrary, Gen, TestResult}; - - use crate::schema::tests::validate_with_schema; - use super::*; + use crate::schema::tests::validate_with_schema; + use quickcheck::{quickcheck, Arbitrary, Gen, TestResult}; quickcheck! { - fn subcription(s: Subscription) -> TestResult { - validate_with_schema("subscription", s) + fn subcription_legacy(s: SubscriptionLegacy) -> TestResult { + validate_with_schema("subscription_legacy", s) } fn activate_subcription(s: ActivateSubscription) -> TestResult { @@ -238,9 +346,9 @@ pub mod tests { } } - impl Arbitrary for Subscription { + impl Arbitrary for SubscriptionLegacy { fn arbitrary(g: &mut Gen) -> Self { - Subscription { + SubscriptionLegacy { id: String::arbitrary(g), marketplace: String::arbitrary(g), status: String::arbitrary(g), @@ -252,6 +360,18 @@ pub mod tests { } } + impl Arbitrary for Subscription { + fn arbitrary(g: &mut Gen) -> Self { + Subscription { + name: String::arbitrary(g), + is_free_trial: bool::arbitrary(g), + marketplace: Option::arbitrary(g), + start_date: Option::arbitrary(g), + end_date: Option::arbitrary(g), + } + } + } + impl Arbitrary for ActivateSubscription { fn arbitrary(g: &mut Gen) -> Self { ActivateSubscription::create( diff --git a/implementations/rust/ockam/ockam_api/src/date.rs b/implementations/rust/ockam/ockam_api/src/date.rs new file mode 100644 index 00000000000..81d36dfc984 --- /dev/null +++ b/implementations/rust/ockam/ockam_api/src/date.rs @@ -0,0 +1,21 @@ +use crate::ApiError; +use time::format_description::well_known::Iso8601; +use time::OffsetDateTime; + +/// Transform a string that represents an Iso8601 date into a `time::OffsetDateTime` +pub fn parse_date(date: &str) -> ockam_core::Result { + // Add the Z timezone to the date, as the `time` crate requires it + let date = if date.ends_with('Z') { + date.to_string() + } else { + format!("{}Z", date) + }; + OffsetDateTime::parse(&date, &Iso8601::DEFAULT).map_err(|e| ApiError::core(e.to_string())) +} + +/// Check if a string that represents an Iso8601 date is expired, using the `time` crate +pub fn is_expired(date: &str) -> ockam_core::Result { + let date = parse_date(date)?; + let now = OffsetDateTime::now_utc(); + Ok(date < now) +} diff --git a/implementations/rust/ockam/ockam_api/src/lib.rs b/implementations/rust/ockam/ockam_api/src/lib.rs index e5fe3f05d50..0d1f216f0b6 100644 --- a/implementations/rust/ockam/ockam_api/src/lib.rs +++ b/implementations/rust/ockam/ockam_api/src/lib.rs @@ -43,6 +43,7 @@ pub mod logs; mod schema; mod session; +mod date; mod rendezvous_healthcheck; pub mod test_utils; mod ui; diff --git a/implementations/rust/ockam/ockam_api/src/nodes/models/services.rs b/implementations/rust/ockam/ockam_api/src/nodes/models/services.rs index 68de26633c8..f5d04c4b0ca 100644 --- a/implementations/rust/ockam/ockam_api/src/nodes/models/services.rs +++ b/implementations/rust/ockam/ockam_api/src/nodes/models/services.rs @@ -241,10 +241,4 @@ impl Output for ServiceStatus { writeln!(f, "{}{}", fmt::PADDING, self)?; Ok(f) } - - fn as_list_item(&self) -> crate::Result { - let mut f = String::new(); - writeln!(f, "{}", self)?; - Ok(f) - } } diff --git a/implementations/rust/ockam/ockam_api/src/schema/schema.cddl b/implementations/rust/ockam/ockam_api/src/schema/schema.cddl index 4a8013a4dd9..c4339e35f90 100644 --- a/implementations/rust/ockam/ockam_api/src/schema/schema.cddl +++ b/implementations/rust/ockam/ockam_api/src/schema/schema.cddl @@ -19,14 +19,12 @@ space = { 1: space_id 2: space_name 3: [+ user] + ?4: subscription } user = text - spaces = [* space] - - create_space = { ?0: 3888657, 1: space_name @@ -237,6 +235,14 @@ onetime_code = { ;;; Subscription ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; subscription = { + 1: text, ;; name + 2: bool, ;; is_free_trial + ?3: text, ;; marketplace + ?4: text, ;; start_date + ?5: text, ;; end_date +} + +subscription_legacy = { 1: text, ;; id 2: text, ;; marketplace 3: text, ;; status diff --git a/implementations/rust/ockam/ockam_api/src/ui/output/mod.rs b/implementations/rust/ockam/ockam_api/src/ui/output/mod.rs index ac8b389ab19..be4469badca 100644 --- a/implementations/rust/ockam/ockam_api/src/ui/output/mod.rs +++ b/implementations/rust/ockam/ockam_api/src/ui/output/mod.rs @@ -9,8 +9,10 @@ pub use utils::*; use crate::Result; +use crate::terminal::fmt; +use itertools::Itertools; use ockam_core::api::Reply; -use std::fmt::Write; +use std::fmt::{Display, Write}; /// Trait to control how a given type will be printed in the UI layer. /// @@ -37,10 +39,34 @@ use std::fmt::Write; /// } /// ``` pub trait Output { + /// Format to use when the item is printed as a standalone item fn item(&self) -> Result; + /// Format to use when the item is part of a list. + /// By default, the list representation is the same as the standalone representation + /// but removing the padding of each line. fn as_list_item(&self) -> Result { - self.item() + Ok(self + .item()? + .lines() + .map(|l| l.strip_prefix(fmt::PADDING).unwrap_or(l)) + .join("\n")) + } + + /// Adds padding to each line of the Display output + fn padded_display(&self) -> String + where + Self: Display, + { + self.iter_output().pad().to_string() + } + + /// Returns an iterator over the lines of the Display output + fn iter_output(&self) -> OutputIter + where + Self: Display, + { + OutputIter::new(self.to_string()) } } @@ -81,3 +107,39 @@ impl Output for Reply { } } } + +pub struct OutputIter { + contents: String, +} + +impl Display for OutputIter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.contents) + } +} + +impl OutputIter { + pub fn new(contents: String) -> Self { + Self { contents } + } + + pub fn pad(self) -> Self { + let contents = self + .contents + .lines() + .map(|s| format!("{}{}", fmt::PADDING, s)) + .collect::>() + .join("\n"); + Self { contents } + } + + pub fn indent(self) -> Self { + let contents = self + .contents + .lines() + .map(|s| format!("{}{}", fmt::INDENTATION, s)) + .collect::>() + .join("\n"); + Self { contents } + } +} diff --git a/implementations/rust/ockam/ockam_api/src/ui/output/utils.rs b/implementations/rust/ockam/ockam_api/src/ui/output/utils.rs index 348d62f1900..053783dcf8d 100644 --- a/implementations/rust/ockam/ockam_api/src/ui/output/utils.rs +++ b/implementations/rust/ockam/ockam_api/src/ui/output/utils.rs @@ -5,10 +5,14 @@ use colorful::Colorful; use ockam::identity::TimestampInSeconds; pub fn comma_separated>(data: &[T]) -> String { - data.iter() - .map(AsRef::as_ref) - .collect::>() - .join(", ") + if data.is_empty() { + "-".to_string() + } else { + data.iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + } } pub fn human_readable_time(time: TimestampInSeconds) -> String { diff --git a/implementations/rust/ockam/ockam_command/src/enroll/command.rs b/implementations/rust/ockam/ockam_command/src/enroll/command.rs index 9ef4f50be3c..d7a89944b91 100644 --- a/implementations/rust/ockam/ockam_command/src/enroll/command.rs +++ b/implementations/rust/ockam/ockam_command/src/enroll/command.rs @@ -14,6 +14,12 @@ use tokio::sync::Mutex; use tokio::try_join; use tracing::{error, info, instrument, warn}; +use crate::enroll::OidcServiceExt; +use crate::error::Error; +use crate::operation::util::check_for_project_completion; +use crate::project::util::check_project_readiness; +use crate::util::async_cmd; +use crate::{docs, CommandGlobalOpts, Result}; use ockam::Context; use ockam_api::cli_state::journeys::{JourneyEvent, USER_EMAIL, USER_NAME}; use ockam_api::cli_state::random_name; @@ -30,13 +36,6 @@ use ockam_api::terminal::notification::NotificationHandler; use ockam_api::{fmt_log, fmt_ok, fmt_warn}; use ockam_api::{fmt_separator, CliState}; -use crate::enroll::OidcServiceExt; -use crate::error::Error; -use crate::operation::util::check_for_project_completion; -use crate::project::util::check_project_readiness; -use crate::util::async_cmd; -use crate::{docs, CommandGlobalOpts, Result}; - const LONG_ABOUT: &str = include_str!("./static/long_about.txt"); const AFTER_LONG_HELP: &str = include_str!("./static/after_long_help.txt"); @@ -417,7 +416,7 @@ async fn get_user_space( let progress_output = opts.terminal.loop_messages(&message, &is_finished); let (spaces, _) = try_join!(get_spaces, progress_output)?; - + let mut is_new = false; // If the identity has no spaces, create one let space = match spaces.first() { None => { @@ -449,6 +448,7 @@ async fn get_user_space( "Created a new Space named {}.", color_primary(space.name.clone()) ))?; + is_new = true; space } Some(space) => { @@ -463,12 +463,9 @@ async fn get_user_space( "Marked {} as your default Space, on this machine.\n", color_primary(space.name.clone()) ))?; - - opts.terminal.write_line(fmt_log!("This Space does not have a Subscription attached to it."))? - .write_line(fmt_log!("As a courtesy, we created a temporary Space for you, so you can continue to build.\n"))? - .write_line(fmt_log!("Please subscribe to an Ockam plan within two weeks {}", color_uri("https://www.ockam.io/pricing")))? - .write_line(fmt_log!("{}\n", "If you don't subscribe in that time, your Space and all Projects will be permanently deleted.".color(OckamColor::FmtWARNBackground.color())))?; - + if let Ok(msg) = space.subscription_status_message(is_new) { + opts.terminal.write_line(msg)?; + } Ok(Some(space)) } diff --git a/implementations/rust/ockam/ockam_command/src/project_member/mod.rs b/implementations/rust/ockam/ockam_command/src/project_member/mod.rs index 0c15469d69a..138f8f0d9aa 100644 --- a/implementations/rust/ockam/ockam_command/src/project_member/mod.rs +++ b/implementations/rust/ockam/ockam_command/src/project_member/mod.rs @@ -191,30 +191,32 @@ impl MemberOutput { attributes, } } +} - fn to_string(&self, padding: &str) -> ockam_api::Result { +impl Output for MemberOutput { + fn item(&self) -> ockam_api::Result { let mut f = String::new(); writeln!( f, "{}{}", - padding, + fmt::PADDING, color_primary(self.identifier.to_string()) )?; if self.attributes.attrs().is_empty() { - writeln!(f, "{}Has no attributes", padding)?; + writeln!(f, "{}Has no attributes", fmt::PADDING)?; } else { let attributes = self.attributes.deserialized_key_value_attrs(); writeln!( f, "{}With attributes: {}", - padding, + fmt::PADDING, color_primary(attributes.join(", ")) )?; writeln!( f, "{}{}Added at: {}", - padding, + fmt::PADDING, fmt::INDENTATION, color_warn(self.attributes.added_at().to_string()) )?; @@ -222,7 +224,7 @@ impl MemberOutput { writeln!( f, "{}{}Expires at: {}", - padding, + fmt::PADDING, fmt::INDENTATION, color_warn(expires_at.to_string()) )?; @@ -231,7 +233,7 @@ impl MemberOutput { writeln!( f, "{}{}Attested by: {}", - padding, + fmt::PADDING, fmt::INDENTATION, color_primary(attested_by.to_string()) )?; @@ -240,13 +242,3 @@ impl MemberOutput { Ok(f) } } - -impl Output for MemberOutput { - fn item(&self) -> ockam_api::Result { - self.to_string(fmt::PADDING) - } - - fn as_list_item(&self) -> ockam_api::Result { - self.to_string("") - } -} diff --git a/implementations/rust/ockam/ockam_command/src/reset.rs b/implementations/rust/ockam/ockam_command/src/reset.rs index 5cefdf50035..bbe587bba03 100644 --- a/implementations/rust/ockam/ockam_command/src/reset.rs +++ b/implementations/rust/ockam/ockam_command/src/reset.rs @@ -21,7 +21,7 @@ pub struct ResetCommand { yes: bool, /// Remove your spaces from the Orchestrator - #[arg(long)] + #[arg(long, short)] all: bool, } diff --git a/implementations/rust/ockam/ockam_command/src/space/create.rs b/implementations/rust/ockam/ockam_command/src/space/create.rs index 35d4044d773..a1bc03c2bbd 100644 --- a/implementations/rust/ockam/ockam_command/src/space/create.rs +++ b/implementations/rust/ockam/ockam_command/src/space/create.rs @@ -1,17 +1,16 @@ +use async_trait::async_trait; use clap::Args; -use colorful::Colorful; use miette::miette; use ockam::Context; use ockam_api::cli_state::random_name; use ockam_api::cloud::space::Spaces; use ockam_api::nodes::InMemoryNode; +use ockam_api::output::Output; use crate::shared_args::IdentityOpts; -use crate::util::async_cmd; use crate::util::validators::cloud_resource_name_validator; -use crate::{docs, CommandGlobalOpts}; -use ockam_api::output::Output; +use crate::{docs, Command, CommandGlobalOpts, Result}; const LONG_ABOUT: &str = include_str!("./static/create/long_about.txt"); const AFTER_LONG_HELP: &str = include_str!("./static/create/after_long_help.txt"); @@ -35,61 +34,52 @@ pub struct CreateCommand { pub identity_opts: IdentityOpts, } -impl CreateCommand { - pub fn run(self, opts: CommandGlobalOpts) -> miette::Result<()> { - async_cmd(&self.name(), opts.clone(), |ctx| async move { - self.async_run(&ctx, opts).await - }) - } - - pub fn name(&self) -> String { - "space crate".into() - } +#[async_trait] +impl Command for CreateCommand { + const NAME: &'static str = "space create"; - async fn async_run(&self, ctx: &Context, opts: CommandGlobalOpts) -> miette::Result<()> { + async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> Result<()> { if !opts .state .is_identity_enrolled(&self.identity_opts.identity_name) .await? { - return Err(miette!( - "Please enroll using 'ockam enroll' before using this command" - )); + return Err( + miette!("Please enroll using 'ockam enroll' before using this command").into(), + ); }; - opts.terminal.write_line(format!( - "\n{}", - "Creating a trial space for you (everything in it will be deleted in 15 days) ..." - .light_magenta(), - ))?; - opts.terminal.write_line(format!( - "{}", - "To learn more about production ready spaces in Ockam Orchestrator, contact us at: hello@ockam.io".light_magenta() - ))?; - let node = InMemoryNode::start(ctx, &opts.state).await?; - let space = node - .create_space( + + let space = { + let pb = opts.terminal.progress_bar(); + if let Some(pb) = pb.as_ref() { + pb.set_message("Creating a Space for you..."); + } + node.create_space( ctx, &self.name, self.admins.iter().map(|a| a.as_ref()).collect(), ) - .await?; - + .await? + }; + if let Ok(msg) = space.subscription_status_message(true) { + opts.terminal.write_line(msg)?; + } opts.terminal .stdout() .plain(space.item()?) - .json(serde_json::json!(&space)) + .json_obj(&space)? .write_line()?; Ok(()) } } -fn validate_space_name(s: &str) -> Result { +fn validate_space_name(s: &str) -> std::result::Result { match cloud_resource_name_validator(s) { Ok(_) => Ok(s.to_string()), Err(_e) => Err(String::from( - "space name can contain only alphanumeric characters and the '-', '_' and '.' separators. \ + "The Space name can contain only alphanumeric characters and the '-', '_' and '.' separators. \ Separators must occur between alphanumeric characters. This implies that separators can't \ occur at the start or end of the name, nor they can occur in sequence.", )) diff --git a/implementations/rust/ockam/ockam_command/src/space/delete.rs b/implementations/rust/ockam/ockam_command/src/space/delete.rs index 8a8f52b2ea5..ce179d149bf 100644 --- a/implementations/rust/ockam/ockam_command/src/space/delete.rs +++ b/implementations/rust/ockam/ockam_command/src/space/delete.rs @@ -1,21 +1,19 @@ +use async_trait::async_trait; use clap::Args; use colorful::Colorful; use console::Term; -use miette::IntoDiagnostic; -use crate::{docs, CommandGlobalOpts}; +use crate::{docs, Command, CommandGlobalOpts}; use ockam::Context; use ockam_api::cloud::space::Spaces; use ockam_api::colors::OckamColor; use ockam_api::nodes::InMemoryNode; use ockam_api::terminal::{Terminal, TerminalStream}; use ockam_api::{color, fmt_ok}; -use ockam_core::AsyncTryClone; use crate::shared_args::IdentityOpts; use crate::terminal::tui::DeleteCommandTui; use crate::tui::PluralTerm; -use crate::util::async_cmd; const LONG_ABOUT: &str = include_str!("./static/delete/long_about.txt"); const AFTER_LONG_HELP: &str = include_str!("./static/delete/after_long_help.txt"); @@ -39,41 +37,29 @@ pub struct DeleteCommand { yes: bool, } -impl DeleteCommand { - pub fn run(self, opts: CommandGlobalOpts) -> miette::Result<()> { - async_cmd(&self.name(), opts.clone(), |ctx| async move { - self.async_run(&ctx, opts).await - }) - } - - pub fn name(&self) -> String { - "space delete".into() - } +#[async_trait] +impl Command for DeleteCommand { + const NAME: &'static str = "space delete"; - async fn async_run(&self, ctx: &Context, opts: CommandGlobalOpts) -> miette::Result<()> { - DeleteTui::run( - ctx.async_try_clone().await.into_diagnostic()?, - opts, - self.clone(), - ) - .await + async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> crate::Result<()> { + Ok(DeleteTui::run(ctx, opts, self).await?) } } -pub struct DeleteTui { - ctx: Context, +pub struct DeleteTui<'a> { + ctx: &'a Context, opts: CommandGlobalOpts, node: InMemoryNode, cmd: DeleteCommand, } -impl DeleteTui { +impl<'a> DeleteTui<'a> { pub async fn run( - ctx: Context, + ctx: &'a Context, opts: CommandGlobalOpts, cmd: DeleteCommand, ) -> miette::Result<()> { - let node = InMemoryNode::start(&ctx, &opts.state).await?; + let node = InMemoryNode::start(ctx, &opts.state).await?; let tui = Self { ctx, opts, @@ -85,7 +71,7 @@ impl DeleteTui { } #[ockam_core::async_trait] -impl DeleteCommandTui for DeleteTui { +impl<'a> DeleteCommandTui for DeleteTui<'a> { const ITEM_NAME: PluralTerm = PluralTerm::Space; fn cmd_arg_item_name(&self) -> Option { @@ -116,7 +102,7 @@ impl DeleteCommandTui for DeleteTui { } async fn delete_single(&self, item_name: &str) -> miette::Result<()> { - self.node.delete_space_by_name(&self.ctx, item_name).await?; + self.node.delete_space_by_name(self.ctx, item_name).await?; self.terminal() .stdout() diff --git a/implementations/rust/ockam/ockam_command/src/space/list.rs b/implementations/rust/ockam/ockam_command/src/space/list.rs index 2d8a3ef449c..f9a59269b34 100644 --- a/implementations/rust/ockam/ockam_command/src/space/list.rs +++ b/implementations/rust/ockam/ockam_command/src/space/list.rs @@ -1,16 +1,12 @@ +use async_trait::async_trait; use clap::Args; -use miette::IntoDiagnostic; -use opentelemetry::trace::FutureExt; -use tokio::sync::Mutex; -use tokio::try_join; use ockam::Context; use ockam_api::cloud::space::Spaces; use ockam_api::nodes::InMemoryNode; use crate::shared_args::IdentityOpts; -use crate::util::async_cmd; -use crate::{docs, CommandGlobalOpts}; +use crate::{docs, Command, CommandGlobalOpts}; const LONG_ABOUT: &str = include_str!("./static/list/long_about.txt"); const PREVIEW_TAG: &str = include_str!("../static/preview_tag.txt"); @@ -28,44 +24,30 @@ pub struct ListCommand { pub identity_opts: IdentityOpts, } -impl ListCommand { - pub fn run(self, opts: CommandGlobalOpts) -> miette::Result<()> { - async_cmd(&self.name(), opts.clone(), |ctx| async move { - self.async_run(&ctx, opts).await - }) - } - - pub fn name(&self) -> String { - "space list".into() - } +#[async_trait] +impl Command for ListCommand { + const NAME: &'static str = "space list"; - async fn async_run(&self, ctx: &Context, opts: CommandGlobalOpts) -> miette::Result<()> { - let is_finished: Mutex = Mutex::new(false); + async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> crate::Result<()> { let node = InMemoryNode::start(ctx, &opts.state).await?; - let get_spaces = async { - let spaces = node.get_spaces(ctx).await?; - *is_finished.lock().await = true; - Ok(spaces) - } - .with_current_context(); - - let output_messages = vec![format!("Listing Spaces...\n",)]; - - let progress_output = opts.terminal.loop_messages(&output_messages, &is_finished); - - let (spaces, _) = try_join!(get_spaces, progress_output)?; + let spaces = { + let pb = opts.terminal.progress_bar(); + if let Some(pb) = pb.as_ref() { + pb.set_message("Listing spaces..."); + } + node.get_spaces(ctx).await? + }; let plain = opts.terminal.build_list( &spaces, "No spaces found. Run 'ockam enroll' to get a space and a project", )?; - let json = serde_json::to_string(&spaces).into_diagnostic()?; opts.terminal .stdout() .plain(plain) - .json(json) + .json_obj(&spaces)? .write_line()?; Ok(()) } diff --git a/implementations/rust/ockam/ockam_command/src/space/mod.rs b/implementations/rust/ockam/ockam_command/src/space/mod.rs index e2aac33089c..fd68e62951c 100644 --- a/implementations/rust/ockam/ockam_command/src/space/mod.rs +++ b/implementations/rust/ockam/ockam_command/src/space/mod.rs @@ -5,7 +5,7 @@ pub use delete::DeleteCommand; pub use list::ListCommand; pub use show::ShowCommand; -use crate::{docs, CommandGlobalOpts}; +use crate::{docs, Command, CommandGlobalOpts}; mod create; mod delete; diff --git a/implementations/rust/ockam/ockam_command/src/space/show.rs b/implementations/rust/ockam/ockam_command/src/space/show.rs index ebca02d56b8..b0fd8a1a7c1 100644 --- a/implementations/rust/ockam/ockam_command/src/space/show.rs +++ b/implementations/rust/ockam/ockam_command/src/space/show.rs @@ -1,18 +1,16 @@ +use async_trait::async_trait; use clap::Args; use console::Term; -use miette::IntoDiagnostic; -use crate::{docs, CommandGlobalOpts}; +use crate::{docs, Command, CommandGlobalOpts}; use ockam::Context; use ockam_api::cloud::space::Spaces; use ockam_api::nodes::InMemoryNode; use ockam_api::terminal::{Terminal, TerminalStream}; -use ockam_core::AsyncTryClone; use crate::shared_args::IdentityOpts; use crate::terminal::tui::ShowCommandTui; use crate::tui::PluralTerm; -use crate::util::async_cmd; use ockam_api::output::Output; const LONG_ABOUT: &str = include_str!("./static/show/long_about.txt"); @@ -35,41 +33,29 @@ pub struct ShowCommand { pub identity_opts: IdentityOpts, } -impl ShowCommand { - pub fn run(self, opts: CommandGlobalOpts) -> miette::Result<()> { - async_cmd(&self.name(), opts.clone(), |ctx| async move { - self.async_run(&ctx, opts).await - }) - } - - pub fn name(&self) -> String { - "space show".into() - } +#[async_trait] +impl Command for ShowCommand { + const NAME: &'static str = "space show"; - async fn async_run(&self, ctx: &Context, opts: CommandGlobalOpts) -> miette::Result<()> { - ShowTui::run( - ctx.async_try_clone().await.into_diagnostic()?, - opts, - self.clone(), - ) - .await + async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> crate::Result<()> { + Ok(ShowTui::run(ctx, opts, self).await?) } } -pub struct ShowTui { - ctx: Context, +pub struct ShowTui<'a> { + ctx: &'a Context, opts: CommandGlobalOpts, space_name: Option, node: InMemoryNode, } -impl ShowTui { +impl<'a> ShowTui<'a> { pub async fn run( - ctx: Context, + ctx: &'a Context, opts: CommandGlobalOpts, cmd: ShowCommand, ) -> miette::Result<()> { - let node = InMemoryNode::start(&ctx, &opts.state).await?; + let node = InMemoryNode::start(ctx, &opts.state).await?; let tui = Self { ctx, opts, @@ -81,7 +67,7 @@ impl ShowTui { } #[ockam_core::async_trait] -impl ShowCommandTui for ShowTui { +impl<'a> ShowCommandTui for ShowTui<'a> { const ITEM_NAME: PluralTerm = PluralTerm::Space; fn cmd_arg_item_name(&self) -> Option { @@ -103,7 +89,7 @@ impl ShowCommandTui for ShowTui { async fn list_items_names(&self) -> miette::Result> { Ok(self .node - .get_spaces(&self.ctx) + .get_spaces(self.ctx) .await? .iter() .map(|s| s.space_name()) @@ -111,11 +97,11 @@ impl ShowCommandTui for ShowTui { } async fn show_single(&self, item_name: &str) -> miette::Result<()> { - let space = self.node.get_space_by_name(&self.ctx, item_name).await?; + let space = self.node.get_space_by_name(self.ctx, item_name).await?; self.terminal() .stdout() .plain(space.item()?) - .json(serde_json::to_string(&space).into_diagnostic()?) + .json_obj(&space)? .machine(&space.name) .write_line()?; Ok(()) diff --git a/implementations/rust/ockam/ockam_command/src/status.rs b/implementations/rust/ockam/ockam_command/src/status.rs index 46039e8bd6c..23ff420a728 100644 --- a/implementations/rust/ockam/ockam_command/src/status.rs +++ b/implementations/rust/ockam/ockam_command/src/status.rs @@ -7,19 +7,21 @@ use miette::IntoDiagnostic; use serde::Serialize; use tracing::warn; +use crate::node::show::get_node_resources; +use crate::shared_args::TimeoutArg; +use crate::version::Version; +use crate::Result; +use crate::{Command, CommandGlobalOpts}; use ockam::Context; use ockam_api::cli_state::{EnrollmentFilter, IdentityEnrollment}; use ockam_api::cloud::project::models::OrchestratorVersionInfo; +use ockam_api::cloud::space::Space; use ockam_api::colors::color_primary; use ockam_api::nodes::models::node::NodeResources; use ockam_api::nodes::{BackgroundNodeClient, InMemoryNode}; +use ockam_api::output::Output; use ockam_api::{fmt_heading, fmt_log, fmt_separator, fmt_warn}; -use crate::node::show::get_node_resources; -use crate::shared_args::TimeoutArg; -use crate::Result; -use crate::{Command, CommandGlobalOpts}; - /// Display information about the system's status #[derive(Clone, Debug, Args)] pub struct StatusCommand { @@ -34,18 +36,18 @@ impl Command for StatusCommand { async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> Result<()> { let identities_details = self.get_identities_details(&opts).await?; let nodes = self.get_nodes_resources(ctx, &opts).await?; - let orchestrator_version = { - let node = InMemoryNode::start(ctx, &opts.state) - .await? - .with_timeout(self.timeout.timeout); - let controller = node.create_controller().await?; - controller - .get_orchestrator_version_info(ctx) - .await - .map_err(|e| warn!(%e, "Failed to retrieve orchestrator version")) - .unwrap_or_default() - }; - let status = StatusData::from_parts(orchestrator_version, identities_details, nodes)?; + let node = InMemoryNode::start(ctx, &opts.state) + .await? + .with_timeout(self.timeout.timeout); + let controller = node.create_controller().await?; + let orchestrator_version = controller + .get_orchestrator_version_info(ctx) + .await + .map_err(|e| warn!(%e, "Failed to retrieve orchestrator version")) + .unwrap_or_default(); + let spaces = opts.state.get_spaces().await?; + let status = + StatusData::from_parts(orchestrator_version, spaces, identities_details, nodes)?; opts.terminal .stdout() .plain(&status) @@ -91,8 +93,9 @@ impl StatusCommand { #[derive(Serialize)] struct StatusData { - #[serde(flatten)] + ockam_version: Version, orchestrator_version: OrchestratorVersionInfo, + spaces: Vec, identities: Vec, nodes: Vec, } @@ -100,11 +103,14 @@ struct StatusData { impl StatusData { fn from_parts( orchestrator_version: OrchestratorVersionInfo, + spaces: Vec, identities: Vec, nodes: Vec, ) -> Result { Ok(Self { + ockam_version: Version, orchestrator_version, + spaces, identities, nodes, }) @@ -113,6 +119,14 @@ impl StatusData { impl Display for StatusData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "{}", + fmt_log!( + "Ockam version: {}", + color_primary(self.ockam_version.to_string()) + ) + )?; writeln!( f, "{}", @@ -130,6 +144,24 @@ impl Display for StatusData { ) )?; + if self.spaces.is_empty() { + writeln!(f, "{}", fmt_separator!())?; + writeln!(f, "{}", fmt_warn!("No spaces found"))?; + writeln!( + f, + "{}", + fmt_log!("Consider running `ockam enroll` or `ockam space create` to create your first space.") + )?; + } else { + writeln!(f, "{}", fmt_heading!("Spaces"))?; + for (idx, space) in self.spaces.iter().enumerate() { + if idx > 0 { + writeln!(f)?; + } + writeln!(f, "{}", space.iter_output().pad())?; + } + } + if self.identities.is_empty() { writeln!(f, "{}", fmt_separator!())?; writeln!(f, "{}", fmt_warn!("No identities found"))?; diff --git a/implementations/rust/ockam/ockam_command/src/subscription.rs b/implementations/rust/ockam/ockam_command/src/subscription.rs index 6959c123ea9..7651b6568ed 100644 --- a/implementations/rust/ockam/ockam_command/src/subscription.rs +++ b/implementations/rust/ockam/ockam_command/src/subscription.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use miette::{miette, IntoDiagnostic}; use ockam::Context; -use ockam_api::cloud::subscription::{Subscription, Subscriptions}; +use ockam_api::cloud::subscription::{SubscriptionLegacy, Subscriptions}; use ockam_api::cloud::ControllerClient; use ockam_api::nodes::InMemoryNode; @@ -92,7 +92,7 @@ pub(crate) async fn get_subscription_by_id_or_space_id( ctx: &Context, subscription_id: Option, space_id: Option, -) -> Result> { +) -> Result> { match (subscription_id, space_id) { (Some(subscription_id), _) => Ok(Some( controller diff --git a/implementations/rust/ockam/ockam_command/src/version.rs b/implementations/rust/ockam/ockam_command/src/version.rs index e948d33b79d..c5d57a8b151 100644 --- a/implementations/rust/ockam/ockam_command/src/version.rs +++ b/implementations/rust/ockam/ockam_command/src/version.rs @@ -1,6 +1,10 @@ //! Helpers to display version information use clap::crate_version; +use ockam_api::colors::color_primary; +use serde::ser::SerializeStruct; +use serde::Serialize; +use std::fmt::Display; pub(crate) struct Version; @@ -10,12 +14,38 @@ impl Version { } pub(crate) fn short() -> &'static str { - let crate_version = crate_version!(); - let mut git_hash = env!("GIT_HASH"); - if git_hash.is_empty() { - git_hash = "N/A"; - } - let message = format!("{crate_version}\ncompiled from: {git_hash}"); + let message = format!("{}\ncompiled from: {}", Self::version(), Self::hash()); Box::leak(message.into_boxed_str()) } + + fn version() -> &'static str { + crate_version!() + } + + fn hash() -> &'static str { + option_env!("GIT_HASH").unwrap_or("N/A") + } +} + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}, compiled from: {}", + color_primary(Self::version()), + color_primary(Self::hash()) + ) + } +} + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("Version", 2)?; + state.serialize_field("version", Self::version())?; + state.serialize_field("hash", &Self::hash())?; + state.end() + } } diff --git a/implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/postgres/20240703100000_add_subscriptions.sql b/implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/postgres/20240703100000_add_subscriptions.sql new file mode 100644 index 00000000000..28ecd731a0a --- /dev/null +++ b/implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/postgres/20240703100000_add_subscriptions.sql @@ -0,0 +1,9 @@ +-- This migration adds the subscriptions table based on the following rust struct +CREATE TABLE subscription ( + space_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_free_trial BOOLEAN NOT NULL, + marketplace TEXT, + start_date INTEGER, + end_date INTEGER +); diff --git a/implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/sqlite/20240703100000_add_subscriptions.sql b/implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/sqlite/20240703100000_add_subscriptions.sql new file mode 100644 index 00000000000..552c1e9b381 --- /dev/null +++ b/implementations/rust/ockam/ockam_node/src/storage/database/migrations/node_migrations/sql/sqlite/20240703100000_add_subscriptions.sql @@ -0,0 +1,9 @@ +-- This migration adds the subscriptions table based on the following rust struct +CREATE TABLE subscription ( + space_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_free_trial INTEGER NOT NULL, + marketplace TEXT, + start_date INTEGER, + end_date INTEGER +);