diff --git a/coffee_cmd/src/coffee_term/command_show.rs b/coffee_cmd/src/coffee_term/command_show.rs index bece9a95..20693153 100644 --- a/coffee_cmd/src/coffee_term/command_show.rs +++ b/coffee_cmd/src/coffee_term/command_show.rs @@ -6,7 +6,7 @@ use radicle_term::table::TableOptions; use coffee_lib::error; use coffee_lib::errors::CoffeeError; -use coffee_lib::types::response::{CoffeeList, CoffeeRemote}; +use coffee_lib::types::response::{CoffeeList, CoffeeNurse, CoffeeRemote, NurseStatus}; use term::Element; pub fn show_list(coffee_list: Result) -> Result<(), CoffeeError> { @@ -76,3 +76,51 @@ pub fn show_remote_list(remote_list: Result) -> Resul Ok(()) } + +pub fn show_nurse_result( + nurse_result: Result, +) -> Result<(), CoffeeError> { + match nurse_result { + Ok(nurse) => { + // special case: if the nurse is sane + // we print a message and return + if nurse.status[0] == NurseStatus::Sane { + term::success!("Coffee configuration is not corrupt! No need to run coffee nurse"); + return Ok(()); + } + let mut table = radicle_term::Table::new(TableOptions::bordered()); + table.push([ + term::format::dim(String::from("●")), + term::format::bold(String::from("Actions Taken")), + term::format::bold(String::from("Affected repositories")), + ]); + table.divider(); + + for status in &nurse.status { + let action_str = match status { + NurseStatus::Sane => "".to_string(), + NurseStatus::RepositoryLocallyRestored(_) => "Restored using Git".to_string(), + NurseStatus::RepositoryLocallyRemoved(_) => { + "Removed from local storage".to_string() + } + }; + let repos_str = match status { + NurseStatus::Sane => "".to_string(), + NurseStatus::RepositoryLocallyRestored(repos) + | NurseStatus::RepositoryLocallyRemoved(repos) => repos.join(", "), + }; + + table.push([ + term::format::positive("●").into(), + term::format::bold(action_str.clone()), + term::format::highlight(repos_str.clone()), + ]); + } + + table.print(); + } + Err(err) => eprintln!("{}", err), + } + + Ok(()) +} diff --git a/coffee_cmd/src/main.rs b/coffee_cmd/src/main.rs index 7355076b..ed37abf9 100644 --- a/coffee_cmd/src/main.rs +++ b/coffee_cmd/src/main.rs @@ -124,8 +124,8 @@ async fn main() -> Result<(), CoffeeError> { Err(err) => Err(err), }, CoffeeCommand::Nurse {} => { - term::info!("Nurse command is not implemented"); - Ok(()) + let nurse_result = coffee.nurse().await; + coffee_term::show_nurse_result(nurse_result) } }; diff --git a/coffee_core/src/coffee.rs b/coffee_core/src/coffee.rs index ddcaf684..651ba58f 100644 --- a/coffee_core/src/coffee.rs +++ b/coffee_core/src/coffee.rs @@ -1,5 +1,5 @@ //! Coffee mod implementation -use coffee_storage::nosql_db::NoSQlStorage; + use std::collections::HashMap; use std::fmt::Debug; use std::vec::Vec; @@ -23,6 +23,7 @@ use coffee_lib::types::response::*; use coffee_lib::url::URL; use coffee_lib::{commit_id, error, get_repo_info, sh}; use coffee_storage::model::repository::{Kind, Repository as RepositoryInfo}; +use coffee_storage::nosql_db::NoSQlStorage; use coffee_storage::storage::StorageManager; use super::config; @@ -61,20 +62,20 @@ impl From<&CoffeeManager> for CoffeeStorageInfo { } pub struct CoffeeManager { - config: config::CoffeeConf, - repos: HashMap>, + pub config: config::CoffeeConf, + pub repos: HashMap>, /// Core lightning configuration managed by coffee - coffee_cln_config: CLNConf, + pub coffee_cln_config: CLNConf, /// Core lightning configuration that include the /// configuration managed by coffee - cln_config: Option, + pub cln_config: Option, /// storage instance to make all the plugin manager /// information persistent on disk - storage: NoSQlStorage, + pub storage: NoSQlStorage, /// core lightning rpc connection - rpc: Option, + pub rpc: Option, /// Recovery Strategies for the nurse command. - recovery_strategies: RecoveryChainOfResponsibility, + pub recovery_strategies: RecoveryChainOfResponsibility, } impl CoffeeManager { @@ -442,8 +443,67 @@ impl PluginManager for CoffeeManager { Err(err) } - async fn nurse(&mut self) -> Result<(), CoffeeError> { - self.recovery_strategies.scan().await + async fn nurse(&mut self) -> Result { + let status = self.recovery_strategies.scan(self).await?; + let mut nurse_actions: Vec = vec![]; + for defect in status.defects.iter() { + log::debug!("defect: {:?}", defect); + match defect { + Defect::RepositoryLocallyAbsent(repos) => { + let mut actions = self.patch_repository_locally_absent(repos.to_vec()).await?; + nurse_actions.append(&mut actions); + } + } + } + + // If there was no actions taken by nurse, we return a sane status. + if nurse_actions.is_empty() { + nurse_actions.push(NurseStatus::Sane); + } + Ok(CoffeeNurse { + status: nurse_actions, + }) + } + + async fn patch_repository_locally_absent( + &mut self, + repos: Vec, + ) -> Result, CoffeeError> { + // initialize the nurse actions + let mut nurse_actions: Vec = vec![]; + // for every repository that is absent locally + // we try to recover it. + // There are 2 cases: + // 1. the repository can be recovered from the remote + // 2. the repository can't be recovered from the remote. In this case + // we remove the repository from the coffee configuration. + for repo_name in repos.iter() { + // Get the repository from the name + let repo = self + .repos + .get_mut(repo_name) + .ok_or_else(|| error!("repository with name: {repo_name} not found"))?; + + match repo.recover().await { + Ok(_) => { + log::info!("repository {} recovered", repo_name.clone()); + nurse_actions.push(NurseStatus::RepositoryLocallyRestored(vec![ + repo_name.clone() + ])); + } + Err(err) => { + log::debug!("error while recovering repository {repo_name}: {err}"); + log::info!("removing repository {}", repo_name.clone()); + self.repos.remove(repo_name); + log::debug!("remote removed: {}", repo_name); + self.flush().await?; + nurse_actions.push(NurseStatus::RepositoryLocallyRemoved(vec![ + repo_name.clone() + ])); + } + } + } + Ok(nurse_actions) } } diff --git a/coffee_core/src/nurse/chain.rs b/coffee_core/src/nurse/chain.rs index 646978bc..921d3820 100644 --- a/coffee_core/src/nurse/chain.rs +++ b/coffee_core/src/nurse/chain.rs @@ -1,6 +1,6 @@ //! Nurse Chain of Responsibility rust implementation //! -//! If you do not know what Chain Of Responsibility patter +//! If you do not know what Chain Of Responsibility pattern //! is, here is a small description: //! //! > Chain of Responsibility is behavioral design pattern @@ -14,7 +14,7 @@ //! > a standard handler interface. //! //! In our case we do not need to handle a request, but we should -//! handler through the various recovery strategy to see what can +//! handler through the various recovery strategies to see what can //! be applied. //! //! So in our case the handler is a specific recovery strategy @@ -31,14 +31,17 @@ use std::sync::Arc; use async_trait::async_trait; use coffee_lib::errors::CoffeeError; +use coffee_lib::types::response::{ChainOfResponsibilityStatus, Defect}; -use super::strategy::RecoveryStrategy; +use super::strategy::GitRepositoryLocallyAbsentStrategy; +use crate::coffee::CoffeeManager; #[async_trait] pub trait Handler: Send + Sync { - async fn can_be_apply( + async fn can_be_applied( self: Arc, - ) -> Result>, CoffeeError>; + coffee: &CoffeeManager, + ) -> Result, CoffeeError>; } pub struct RecoveryChainOfResponsibility { @@ -46,18 +49,26 @@ pub struct RecoveryChainOfResponsibility { } impl RecoveryChainOfResponsibility { + /// Create a new instance of the chain of responsibility pub async fn new() -> Result { Ok(Self { - handlers: Vec::new(), + handlers: vec![Arc::new(GitRepositoryLocallyAbsentStrategy)], }) } - pub async fn scan(&self) -> Result<(), CoffeeError> { + /// Scan the chain of responsibility to see what can be applied + /// and return the status of the chain of responsibility + /// with the list of defects + pub async fn scan( + &self, + coffee: &CoffeeManager, + ) -> Result { + let mut defects: Vec = vec![]; for handler in self.handlers.iter() { - if let Some(strategy) = handler.clone().can_be_apply().await? { - strategy.patch().await?; + if let Some(defect) = handler.clone().can_be_applied(coffee).await? { + defects.push(defect); } } - Ok(()) + Ok(ChainOfResponsibilityStatus { defects }) } } diff --git a/coffee_core/src/nurse/strategy.rs b/coffee_core/src/nurse/strategy.rs index 460d3c26..a1e85d4d 100644 --- a/coffee_core/src/nurse/strategy.rs +++ b/coffee_core/src/nurse/strategy.rs @@ -21,33 +21,56 @@ //! be able to choose the algorithm at runtime. //! //! Author: Vincenzo Palazzo +use std::path::Path; use std::sync::Arc; use async_trait::async_trait; use coffee_lib::errors::CoffeeError; +use coffee_lib::types::response::Defect; +use crate::coffee::CoffeeManager; use crate::nurse::chain::Handler; -#[async_trait] -pub trait RecoveryStrategy: Send + Sync { - async fn patch(&self) -> Result<(), CoffeeError>; -} - -pub struct GitRepositoryMissedStrategy; - -#[async_trait] -impl RecoveryStrategy for GitRepositoryMissedStrategy { - async fn patch(&self) -> Result<(), CoffeeError> { - unimplemented!() - } -} +/// Strategy for handling the situation when a Git repository exists in coffee configuration +/// but is absent from the local storage. +/// +/// This strategy is invoked when a Git repository is documented in the coffee configuration but +/// is not found in the local storage directory (./coffee/repositories). +/// The absence of the repository locally may be due to reasons such as accidental deletion or +/// a change in the storage location. +pub struct GitRepositoryLocallyAbsentStrategy; #[async_trait] -impl Handler for GitRepositoryMissedStrategy { - async fn can_be_apply( +impl Handler for GitRepositoryLocallyAbsentStrategy { + /// Determines if a repository is missing from local storage. + /// + /// This function iterates over the Git repositories listed in the coffee configuration and + /// checks if each one exists in the `.coffee/repositories` folder. If any repository is found + /// to be missing from local storage, it indicates that the strategy to handle + /// this situation should be applied. + async fn can_be_applied( self: Arc, - ) -> Result>, CoffeeError> { - Ok(Some(self)) + coffee: &CoffeeManager, + ) -> Result, CoffeeError> { + let mut repos: Vec = Vec::new(); + let coffee_repos = &coffee.repos; + for repo in coffee_repos.values() { + log::debug!("Checking if repository {} exists locally", repo.name()); + let repo_path = repo.url().path_string; + let repo_path = Path::new(&repo_path); + if !repo_path.exists() { + log::debug!("Repository {} is missing locally", repo.name()); + repos.push(repo.name().to_string()); + } + } + + if repos.is_empty() { + log::debug!("No repositories missing locally"); + Ok(None) + } else { + log::debug!("Found {} repositories missing locally", repos.len()); + Ok(Some(Defect::RepositoryLocallyAbsent(repos))) + } } } diff --git a/coffee_github/src/repository.rs b/coffee_github/src/repository.rs index d611f5fe..97728f1f 100644 --- a/coffee_github/src/repository.rs +++ b/coffee_github/src/repository.rs @@ -268,6 +268,48 @@ impl Repository for Github { }) } + async fn recover(&mut self) -> Result<(), CoffeeError> { + let commit = self.git_head.clone(); + + log::debug!( + "recovering repository: {} {} > {}", + self.name, + &self.url.url_string, + &self.url.path_string, + ); + // recursively clone the repository + let res = git2::Repository::clone(&self.url.url_string, &self.url.path_string); + match res { + Ok(repo) => { + // get the commit id + let oid = git2::Oid::from_str(&commit.unwrap()) + .map_err(|err| error!("{}", err.message()))?; + // Retrieve the commit associated with the OID + let target_commit = match repo.find_commit(oid) { + Ok(commit) => commit, + Err(err) => return Err(error!("{}", err.message())), + }; + + // Update HEAD to point to the target commit + repo.set_head_detached(target_commit.id()) + .map_err(|err| error!("{}", err.message()))?; + + // retrieve the submodules + let submodules = repo.submodules().unwrap_or_default(); + for (_, sub) in submodules.iter().enumerate() { + let path = + format!("{}/{}", &self.url.path_string, sub.path().to_str().unwrap()); + if let Err(err) = git2::Repository::clone(sub.url().unwrap(), &path) { + return Err(error!("{}", err.message())); + } + } + + Ok(()) + } + Err(err) => Err(error!("{}", err.message())), + } + } + /// list of the plugin installed inside the repository. async fn list(&self) -> Result, CoffeeError> { Ok(self.plugins.clone()) diff --git a/coffee_lib/src/plugin_manager.rs b/coffee_lib/src/plugin_manager.rs index 6028e610..e900d69f 100644 --- a/coffee_lib/src/plugin_manager.rs +++ b/coffee_lib/src/plugin_manager.rs @@ -47,5 +47,12 @@ pub trait PluginManager { async fn search(&mut self, plugin: &str) -> Result; /// clean up storage information about the remote repositories of the plugin manager. - async fn nurse(&mut self) -> Result<(), CoffeeError>; + async fn nurse(&mut self) -> Result; + + /// patch coffee configuration in the case that a repository is present in the coffee + /// configuration but is absent from the local storage. + async fn patch_repository_locally_absent( + &mut self, + repos: Vec, + ) -> Result, CoffeeError>; } diff --git a/coffee_lib/src/repository.rs b/coffee_lib/src/repository.rs index 8b45a293..b34a3ee0 100644 --- a/coffee_lib/src/repository.rs +++ b/coffee_lib/src/repository.rs @@ -27,6 +27,9 @@ pub trait Repository: Any { /// upgrade the repository async fn upgrade(&mut self, plugins: &Vec) -> Result; + /// recover the repository from the commit id. + async fn recover(&mut self) -> Result<(), CoffeeError>; + /// return the name of the repository. fn name(&self) -> String; diff --git a/coffee_lib/src/types/mod.rs b/coffee_lib/src/types/mod.rs index 2edb8f97..f946eb64 100644 --- a/coffee_lib/src/types/mod.rs +++ b/coffee_lib/src/types/mod.rs @@ -85,6 +85,8 @@ pub mod request { // Definition of the response types. pub mod response { + use std::fmt; + use serde::{Deserialize, Serialize}; use crate::plugin::Plugin; @@ -139,4 +141,51 @@ pub mod response { pub repository_url: String, pub plugin: Plugin, } + + /// This struct is used to represent a defect + /// that can be patched by the nurse. + #[derive(Clone, Debug, Serialize, Deserialize)] + pub enum Defect { + // A patch operation when a git repository is present in the coffee configuration + // but is absent from the local storage. + RepositoryLocallyAbsent(Vec), + // TODO: Add more patch operations + } + + #[derive(Clone, Debug, Serialize, Deserialize)] + pub struct ChainOfResponsibilityStatus { + pub defects: Vec, + } + + /// This struct is used to represent the status of nurse, + /// either sane or not. + /// If not sane, return the action that nurse has taken. + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] + pub enum NurseStatus { + Sane, + RepositoryLocallyRestored(Vec), + RepositoryLocallyRemoved(Vec), + } + + #[derive(Clone, Debug, Serialize, Deserialize)] + pub struct CoffeeNurse { + pub status: Vec, + } + + impl fmt::Display for NurseStatus { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + NurseStatus::Sane => write!( + f, + "coffee configuration is not corrupt! No need to run coffee nurse" + ), + NurseStatus::RepositoryLocallyRestored(val) => { + write!(f, "Repositories restored locally: {}", val.join(" ")) + } + NurseStatus::RepositoryLocallyRemoved(val) => { + write!(f, "Repositories removed locally: {}", val.join(" ")) + } + } + } + } } diff --git a/docs/docs-book/src/using-coffee.md b/docs/docs-book/src/using-coffee.md index 3bc27d7f..0a89857a 100644 --- a/docs/docs-book/src/using-coffee.md +++ b/docs/docs-book/src/using-coffee.md @@ -113,8 +113,6 @@ Coffee tightly integrates with git, allowing you to easily upgrade your plugins coffee upgrade ``` - - ### Listing all the plugins > ✅ Implemented @@ -131,13 +129,21 @@ coffee list coffee show ``` -## Searching for a plugin in remote repositories +### Searching for a plugin in remote repositories > ✅ Implemented ```bash coffee search ``` + +### To solve issues with coffee configuration and ensure its integrity on disk + +> ✅ Implemented + +```bash +coffee nurse +``` _________ ## Running coffee as a server diff --git a/tests/src/coffee_integration_tests.rs b/tests/src/coffee_integration_tests.rs index 53956764..5f48b332 100644 --- a/tests/src/coffee_integration_tests.rs +++ b/tests/src/coffee_integration_tests.rs @@ -5,6 +5,7 @@ use tokio::fs; use serde_json::json; use coffee_lib::plugin_manager::PluginManager; +use coffee_lib::types::response::NurseStatus; use coffee_testing::cln::Node; use coffee_testing::prelude::tempfile; use coffee_testing::{CoffeeTesting, CoffeeTestingArgs}; @@ -555,3 +556,107 @@ pub async fn test_plugin_installation_path() { cln.stop().await.unwrap(); } + +#[tokio::test] +#[ntest::timeout(560000)] +pub async fn test_nurse_repository_missing_on_disk() { + init(); + let mut cln = Node::tmp("regtest").await.unwrap(); + + let mut manager = CoffeeTesting::tmp().await.unwrap(); + let lightning_dir = cln.rpc().getinfo().unwrap().ligthning_dir; + let lightning_dir = lightning_dir.strip_suffix("/regtest").unwrap(); + log::info!("lightning path: {lightning_dir}"); + + manager.coffee().setup(&lightning_dir).await.unwrap(); + + // Construct the root path + let root_path = manager + .root_path() + .to_owned() + .path() + .to_str() + .map(String::from); + assert!(root_path.is_some(), "{:?}", root_path); + let root_path = root_path.unwrap(); + + // Add folgore remote repository + manager + .coffee() + .add_remote("folgore", "https://github.com/coffee-tools/folgore.git") + .await + .unwrap(); + + // Construct the path of the folgore repository + let folgore_path = format!("{}/.coffee/repositories/folgore", root_path); + let folgore_path = Path::new(&folgore_path); + // Check if the folgore repository exists + assert!( + folgore_path.exists(), + "The folder {:?} does not exist", + folgore_path + ); + + // Make sure that the folgore repository has README.md file + // This is to ensure that the repository is cloned correctly later + // (not just an empty folder) + let folgore_readme_path = format!("{}/.coffee/repositories/folgore/README.md", root_path); + let folgore_readme_path = Path::new(&folgore_readme_path); + // Check if the folgore repository has README.md file + assert!( + folgore_readme_path.exists(), + "The file {:?} does not exist", + folgore_readme_path + ); + + // Assert that nurse returns that coffee is Sane + let result = manager.coffee().nurse().await; + assert!(result.is_ok(), "{:?}", result); + let result = result.unwrap(); + // Assert result has only 1 value + assert_eq!(result.status.len(), 1, "{:?}", result); + // Assert that the value is Sane + assert_eq!(result.status[0], NurseStatus::Sane, "{:?}", result); + + // Remove folgore repository (we militate that the repository is missing on disk) + let result = fs::remove_dir_all(&folgore_path).await; + assert!(result.is_ok(), "{:?}", result); + + // Assert that folgore repository is missing on disk + assert!( + !folgore_path.exists(), + "The folder {:?} exists", + folgore_path + ); + + // Run nurse again + // Assert that nurse returns that coffee isn't Sane + let result = manager.coffee().nurse().await; + assert!(result.is_ok(), "{:?}", result); + let result = result.unwrap(); + // Assert result has only 1 value + assert_eq!(result.status.len(), 1, "{:?}", result); + // Assert that the value is RepositoryLocallyRestored + assert_eq!( + result.status[0], + NurseStatus::RepositoryLocallyRestored(vec!["folgore".to_string()]), + "{:?}", + result + ); + + // Assert that the folgore repository is cloned again + assert!( + folgore_path.exists(), + "The folder {:?} does not exist", + folgore_path + ); + + // Assert that the folgore repository has README.md file + assert!( + folgore_readme_path.exists(), + "The file {:?} does not exist", + folgore_readme_path + ); + + cln.stop().await.unwrap(); +}