diff --git a/CHANGELOG.md b/CHANGELOG.md index fd6b3d90d..c69fcf82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +* `loco doctor` now checks for app-specific minimum dependency versions. This should help in upgrades. `doctor` also supports "production only" checks which you can run in production with `loco doctor --production`. This, for example, will check your connections but will not check dependencies. +* + ## v0.12.0 This release have been primarily about cleanups and simplification. diff --git a/Cargo.toml b/Cargo.toml index 243fea6ac..bc67cb384 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" serde_variant = "0.1.2" +toml = "0.8" async-trait = { workspace = true } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index aeca31b39..95074a7cf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,3 +1,21 @@ +## Blessed depdenencies maintenance and `loco doctor` + +Loco contain a few major and "blessed" dependencies, these appear **both** in an app that was generated at the surface level in their `Cargo.toml` and in the core Loco framework. + +If stale, may require an upgrade as a must. + +Example for such dependencies: + +* The `sea-orm-cli` - while Loco uses `SeaORM`, it uses the `SeaORM` CLI to generate entities, and so there may be an incompatibility if `SeaORM` has a too large breaking change between their CLI (which ships separately) and their framework. +* `axum` +* etc. + +This is why we are checking these automatically as part of `loco doctor`. + +We keep minimal version requirements for these. As a maintainer, you can update these **minimal** versions, only if required in [`doctor.rs`](src/doctor.rs). + + + ## Running Tests Before running tests make sure that: diff --git a/docs-site/content/docs/infrastructure/deployment.md b/docs-site/content/docs/infrastructure/deployment.md index b9ae14394..e960be42f 100644 --- a/docs-site/content/docs/infrastructure/deployment.md +++ b/docs-site/content/docs/infrastructure/deployment.md @@ -137,6 +137,13 @@ auth: ``` +## Running `loco doctor` + +You can run `loco doctor` in your server to check the connection health of your environment. + +```sh +$ myapp doctor --production +``` ## Generate diff --git a/examples/demo/Cargo.lock b/examples/demo/Cargo.lock index 579deb61b..44152ae66 100644 --- a/examples/demo/Cargo.lock +++ b/examples/demo/Cargo.lock @@ -2602,7 +2602,7 @@ dependencies = [ [[package]] name = "loco-gen" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "clap", @@ -2619,7 +2619,7 @@ dependencies = [ [[package]] name = "loco-rs" -version = "0.11.1" +version = "0.12.0" dependencies = [ "argon2", "async-trait", @@ -2665,6 +2665,7 @@ dependencies = [ "thiserror", "tokio", "tokio-cron-scheduler", + "toml", "tower 0.4.13", "tower-http", "tracing", @@ -5044,6 +5045,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.16", +] + [[package]] name = "toml_datetime" version = "0.6.6" @@ -5066,9 +5079,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.14" +version = "0.22.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" +checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" dependencies = [ "indexmap", "serde", @@ -5260,7 +5273,7 @@ dependencies = [ "serde", "shlex", "snapbox", - "toml_edit 0.22.14", + "toml_edit 0.22.16", ] [[package]] diff --git a/examples/demo/tests/cmd/cli.trycmd b/examples/demo/tests/cmd/cli.trycmd index 035e830b2..d3be0c88d 100644 --- a/examples/demo/tests/cmd/cli.trycmd +++ b/examples/demo/tests/cmd/cli.trycmd @@ -131,6 +131,8 @@ $ demo_app-cli doctor ✅ SeaORM CLI is installed ✅ DB connection: success ✅ redis queue: queue connection: success +✅ Dependencies + ``` diff --git a/src/cli.rs b/src/cli.rs index f178d65e4..c093bb998 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -136,6 +136,8 @@ enum Commands { /// print out the current configurations. #[arg(short, long, action)] config: bool, + #[arg(short, long, action)] + production: bool, }, /// Display the app version Version {}, @@ -540,13 +542,16 @@ pub async fn main() -> crate::Result<()> { }, )?; } - Commands::Doctor { config: config_arg } => { + Commands::Doctor { + config: config_arg, + production, + } => { if config_arg { println!("{}", &config); println!("Environment: {}", &environment); } else { let mut should_exit = false; - for (_, check) in doctor::run_all(&config).await? { + for (_, check) in doctor::run_all(&config, production).await? { if !should_exit && !check.valid() { should_exit = true; } diff --git a/src/depcheck.rs b/src/depcheck.rs new file mode 100644 index 000000000..e717bc289 --- /dev/null +++ b/src/depcheck.rs @@ -0,0 +1,221 @@ +use std::collections::HashMap; + +use semver::{Version, VersionReq}; +use thiserror::Error; +use toml::Value; + +#[derive(Debug, PartialEq, Eq, Ord, PartialOrd)] +pub enum VersionStatus { + NotFound, + Invalid { + version: String, + min_version: String, + }, + Ok(String), +} + +#[derive(Debug, PartialEq, Eq, Ord, PartialOrd)] +pub struct CrateStatus { + pub crate_name: String, + pub status: VersionStatus, +} + +#[derive(Error, Debug)] +pub enum VersionCheckError { + #[error("Failed to parse Cargo.lock: {0}")] + ParseError(#[from] toml::de::Error), + + #[error("Error with crate {crate_name}: {msg}")] + CrateError { crate_name: String, msg: String }, +} + +pub type Result = std::result::Result; + +pub fn check_crate_versions( + cargo_lock_content: &str, + min_versions: HashMap<&str, &str>, +) -> Result> { + let lock_file: Value = cargo_lock_content.parse()?; + + let packages = lock_file + .get("package") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + VersionCheckError::ParseError(serde::de::Error::custom( + "Missing package array in Cargo.lock", + )) + })?; + + let mut results = Vec::new(); + + for (crate_name, min_version) in min_versions { + let min_version_req = + VersionReq::parse(min_version).map_err(|_| VersionCheckError::CrateError { + crate_name: crate_name.to_string(), + msg: format!("Invalid minimum version format: {min_version}"), + })?; + + let mut found = false; + for package in packages { + if let Some(name) = package.get("name").and_then(|v| v.as_str()) { + if name == crate_name { + found = true; + let version_str = + package + .get("version") + .and_then(|v| v.as_str()) + .ok_or_else(|| VersionCheckError::CrateError { + crate_name: crate_name.to_string(), + msg: "Invalid version format in Cargo.lock".to_string(), + })?; + + let version = + Version::parse(version_str).map_err(|_| VersionCheckError::CrateError { + crate_name: crate_name.to_string(), + msg: format!("Invalid version format in Cargo.lock: {version_str}"), + })?; + + let status = if min_version_req.matches(&version) { + VersionStatus::Ok(version.to_string()) + } else { + VersionStatus::Invalid { + version: version.to_string(), + min_version: min_version.to_string(), + } + }; + results.push(CrateStatus { + crate_name: crate_name.to_string(), + status, + }); + break; + } + } + } + + if !found { + results.push(CrateStatus { + crate_name: crate_name.to_string(), + status: VersionStatus::NotFound, + }); + } + } + + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_crates_mixed_results() { + let cargo_lock_content = r#" + [[package]] + name = "serde" + version = "1.0.130" + + [[package]] + name = "tokio" + version = "0.3.0" + + [[package]] + name = "rand" + version = "0.8.4" + "#; + + let mut min_versions = HashMap::new(); + min_versions.insert("serde", "1.0.130"); + min_versions.insert("tokio", "1.0"); + min_versions.insert("rand", "0.8.0"); + + let mut result = check_crate_versions(cargo_lock_content, min_versions).unwrap(); + result.sort(); + assert_eq!( + result, + vec![ + CrateStatus { + crate_name: "rand".to_string(), + status: VersionStatus::Ok("0.8.4".to_string()) + }, + CrateStatus { + crate_name: "serde".to_string(), + status: VersionStatus::Ok("1.0.130".to_string()) + }, + CrateStatus { + crate_name: "tokio".to_string(), + status: VersionStatus::Invalid { + version: "0.3.0".to_string(), + min_version: "1.0".to_string() + } + } + ] + ); + } + + #[test] + fn test_invalid_version_format_in_cargo_lock() { + let cargo_lock_content = r#" + [[package]] + name = "serde" + version = "1.0.x" + "#; + + let mut min_versions = HashMap::new(); + min_versions.insert("serde", "1.0.0"); + + let result = check_crate_versions(cargo_lock_content, min_versions); + assert!(matches!( + result, + Err(VersionCheckError::CrateError { crate_name, msg }) if crate_name == "serde" && msg.contains("Invalid version format") + )); + } + + #[test] + fn test_no_package_section_in_cargo_lock() { + let cargo_lock_content = r" + # No packages listed in this Cargo.lock + "; + + let mut min_versions = HashMap::new(); + min_versions.insert("serde", "1.0.130"); + + let result = check_crate_versions(cargo_lock_content, min_versions); + assert!(matches!(result, Err(VersionCheckError::ParseError(_)))); + } + + #[test] + fn test_exact_version_match_for_minimum_requirement() { + let cargo_lock_content = r#" + [[package]] + name = "serde" + version = "1.0.130" + "#; + + let mut min_versions = HashMap::new(); + min_versions.insert("serde", "1.0.130"); + + let mut result = check_crate_versions(cargo_lock_content, min_versions).unwrap(); + result.sort(); + assert_eq!( + result, + vec![CrateStatus { + crate_name: "serde".to_string(), + status: VersionStatus::Ok("1.0.130".to_string()), + }] + ); + } + + #[test] + fn test_no_crates_in_min_versions_map() { + let cargo_lock_content = r#" + [[package]] + name = "serde" + version = "1.0.130" + "#; + + let min_versions = HashMap::new(); // Empty map + + let result = check_crate_versions(cargo_lock_content, min_versions).unwrap(); + assert!(result.is_empty()); + } +} diff --git a/src/doctor.rs b/src/doctor.rs index 78019b332..c576cc922 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,12 +1,17 @@ -use std::{collections::BTreeMap, process::Command}; +use std::{ + collections::{BTreeMap, HashMap}, + process::Command, +}; +use colored::Colorize; +use lazy_static::lazy_static; use regex::Regex; use semver::Version; use crate::{ bgworker, config::{self, Config, Database}, - db, Error, Result, + db, depcheck, Error, Result, }; const SEAORM_INSTALLED: &str = "SeaORM CLI is installed"; @@ -19,12 +24,28 @@ const QUEUE_CONN_OK: &str = "queue connection: success"; const QUEUE_CONN_FAILED: &str = "queue connection: failed"; const QUEUE_NOT_CONFIGURED: &str = "queue not configured?"; +// versions health +const MIN_SEAORMCLI_VER: &str = "1.1.0"; +lazy_static! { + static ref MIN_DEP_VERSIONS: HashMap<&'static str, &'static str> = { + let mut min_vers = HashMap::new(); + + min_vers.insert("tokio", "1.33.0"); + min_vers.insert("sea-orm", "1.1.0"); + min_vers.insert("validator", "0.18.0"); + min_vers.insert("axum", "0.7.5"); + + min_vers + }; +} + /// Represents different resources that can be checked. #[derive(PartialOrd, PartialEq, Eq, Ord, Debug)] pub enum Resource { SeaOrmCLI, Database, - Redis, + Queue, + Deps, } /// Represents the status of a resource check. @@ -93,19 +114,60 @@ impl std::fmt::Display for Check { /// Runs checks for all configured resources. /// # Errors /// Error when one of the checks fail -pub async fn run_all(config: &Config) -> Result> { - let mut checks = BTreeMap::from([ - (Resource::SeaOrmCLI, check_seaorm_cli()?), - (Resource::Database, check_db(&config.database).await), - ]); +pub async fn run_all(config: &Config, production: bool) -> Result> { + let mut checks = BTreeMap::from([(Resource::Database, check_db(&config.database).await)]); if config.workers.mode == config::WorkerMode::BackgroundQueue { - checks.insert(Resource::Redis, check_queue(config).await); + checks.insert(Resource::Queue, check_queue(config).await); + } + + if !production { + checks.insert(Resource::Deps, check_deps()?); + checks.insert(Resource::SeaOrmCLI, check_seaorm_cli()?); } Ok(checks) } +/// Checks "blessed" / major dependencies in a Loco app Cargo.toml, and +/// recommend to update. +/// Only if a dep exists, we check it against a min version +/// # Errors +/// Returns error if fails +pub fn check_deps() -> Result { + let cargolock = fs_err::read_to_string("Cargo.lock")?; + + let crate_statuses = depcheck::check_crate_versions(&cargolock, MIN_DEP_VERSIONS.clone())?; + let mut report = String::new(); + report.push_str("Dependencies\n"); + let mut all_ok = true; + + for status in &crate_statuses { + if let depcheck::VersionStatus::Invalid { + version, + min_version, + } = &status.status + { + report.push_str(&format!( + " {}: version {} does not meet minimum version {}\n", + status.crate_name.yellow(), + version.red(), + min_version.green() + )); + all_ok = false; + } + } + Ok(Check { + status: if all_ok { + CheckStatus::Ok + } else { + CheckStatus::NotOk + }, + message: report, + description: None, + }) +} + /// Checks the database connection. pub async fn check_db(config: &Database) -> Check { match db::connect(config).await { @@ -160,7 +222,6 @@ pub async fn check_queue(config: &Config) -> Check { } } -const MIN_SEAORMCLI_VER: &str = "1.1.0"; /// Checks the presence and version of `SeaORM` CLI. /// # Panics /// On illegal regex diff --git a/src/errors.rs b/src/errors.rs index 58fe9770e..a70aa02c2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,7 +10,7 @@ use axum::{ }; use lettre::{address::AddressError, transport::smtp}; -use crate::controller::ErrorDetail; +use crate::{controller::ErrorDetail, depcheck}; /* backtrace principles: @@ -145,6 +145,9 @@ pub enum Error { #[error(transparent)] Generators(#[from] loco_gen::Error), + #[error(transparent)] + VersionCheck(#[from] depcheck::VersionCheckError), + #[error(transparent)] Any(#[from] Box), } diff --git a/src/lib.rs b/src/lib.rs index dd90cb83e..40c1da358 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub use self::errors::Error; mod banner; pub mod bgworker; +mod depcheck; pub mod initializers; pub mod prelude;