diff --git a/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json b/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json new file mode 100644 index 000000000..037c1c25d --- /dev/null +++ b/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE config SET value = $2 WHERE name = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Json" + ] + }, + "nullable": [] + }, + "hash": "029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838" +} diff --git a/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/Cargo.lock b/Cargo.lock index a5d183fd6..c36d42693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "docs_rs_storage", "docs_rs_test_fakes", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "pretty_assertions", @@ -1948,6 +1949,7 @@ dependencies = [ "docs_rs_storage", "docs_rs_test_fakes", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "opentelemetry", @@ -2050,6 +2052,7 @@ dependencies = [ "docs_rs_opentelemetry", "docs_rs_registry_api", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "hex", diff --git a/crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/cratesfyi/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_admin/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_admin/Cargo.toml b/crates/bin/docs_rs_admin/Cargo.toml index cf7d7e48f..c7c2a55ae 100644 --- a/crates/bin/docs_rs_admin/Cargo.toml +++ b/crates/bin/docs_rs_admin/Cargo.toml @@ -20,6 +20,7 @@ docs_rs_logging = { path = "../../lib/docs_rs_logging" } docs_rs_repository_stats = { path = "../../lib/docs_rs_repository_stats" } docs_rs_storage = { path = "../../lib/docs_rs_storage" } docs_rs_types = { path = "../../lib/docs_rs_types" } +docs_rs_uri = { path = "../../lib/docs_rs_uri" } docs_rs_utils = { path = "../../lib/docs_rs_utils" } futures-util = { workspace = true } sqlx = { workspace = true } diff --git a/crates/bin/docs_rs_admin/src/main.rs b/crates/bin/docs_rs_admin/src/main.rs index 54821f537..d45f466b8 100644 --- a/crates/bin/docs_rs_admin/src/main.rs +++ b/crates/bin/docs_rs_admin/src/main.rs @@ -4,7 +4,7 @@ mod repackage; pub(crate) mod testing; use anyhow::{Context as _, Result, bail}; -use chrono::NaiveDate; +use chrono::{NaiveDate, Utc}; use clap::{Parser, Subcommand}; use docs_rs_build_limits::{Overrides, blacklist}; use docs_rs_build_queue::priority::{ @@ -14,12 +14,13 @@ use docs_rs_build_queue::priority::{ use docs_rs_context::Context; use docs_rs_database::{ crate_details, - service_config::{ConfigName, set_config}, + service_config::{Abnormality, AlertSeverity, AnchorId, ConfigName, remove_config, set_config}, }; use docs_rs_fastly::CdnBehaviour as _; use docs_rs_headers::SurrogateKey; use docs_rs_repository_stats::workspaces; use docs_rs_types::{CrateId, KrateName, ReleaseId, Version}; +use docs_rs_uri::EscapedURI; use futures_util::StreamExt; use rebuilds::queue_rebuilds_faulty_rustdoc; use std::iter; @@ -37,7 +38,7 @@ async fn main() -> Result<()> { Ok(()) } -#[derive(Debug, Clone, PartialEq, Eq, Parser)] +#[derive(Debug, Clone, PartialEq, Parser)] #[command( about = env!("CARGO_PKG_DESCRIPTION"), version = docs_rs_utils::BUILD_VERSION, @@ -350,7 +351,7 @@ impl BuildSubcommand { } } -#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +#[derive(Debug, Clone, PartialEq, Subcommand)] enum DatabaseSubcommand { /// Run database migration Migrate { @@ -359,6 +360,12 @@ enum DatabaseSubcommand { version: Option, }, + /// Manage the abnormality shown in the site header + Abnormality { + #[command(subcommand)] + command: AbnormalitySubcommand, + }, + /// temporary command to repackage missing crates into archive storage. /// starts at the earliest release and works forwards. Repackage { @@ -404,6 +411,8 @@ impl DatabaseSubcommand { } .context("Failed to run database migrations")?, + Self::Abnormality { command } => command.handle_args(ctx).await?, + Self::Repackage { limit } => { let pool = ctx.pool()?; let storage = ctx.storage()?; @@ -504,6 +513,66 @@ impl DatabaseSubcommand { } } +#[derive(Debug, Clone, PartialEq, Subcommand)] +enum AbnormalitySubcommand { + /// Set the abnormality shown in the site header + Set { + #[arg(long)] + url: EscapedURI, + #[arg(long)] + text: String, + /// explanation to be shown on the status page, can be HTML + #[arg(long)] + explanation: Option, + #[arg(long, default_value_t)] + severity: AlertSeverity, + }, + + /// Remove the abnormality shown in the site header + Remove, +} + +impl AbnormalitySubcommand { + async fn handle_args(self, ctx: Context) -> Result<()> { + let mut conn = ctx + .pool()? + .get_async() + .await + .context("failed to get a database connection")?; + + match self { + Self::Set { + url, + text, + explanation, + severity, + } => { + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url, + text, + explanation, + start_time: Some(Utc::now()), + severity, + }, + ) + .await + .context("failed to set abnormality in database")?; + } + Self::Remove => { + remove_config(&mut conn, ConfigName::Abnormality) + .await + .context("failed to remove abnormality from database")?; + } + } + + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] enum LimitsSubcommand { /// Get sandbox limit overrides for a crate diff --git a/crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_builder/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_import_release/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_watcher/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json b/crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json new file mode 100644 index 000000000..037c1c25d --- /dev/null +++ b/crates/bin/docs_rs_web/.sqlx/query-029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE config SET value = $2 WHERE name = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Json" + ] + }, + "nullable": [] + }, + "hash": "029cd6f3f3e44686a9259a8aa2155c044e87f75430006b1c01069d350a3e0838" +} diff --git a/crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/bin/docs_rs_web/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/bin/docs_rs_web/src/error.rs b/crates/bin/docs_rs_web/src/error.rs index 080bb3e5e..8ecda8591 100644 --- a/crates/bin/docs_rs_web/src/error.rs +++ b/crates/bin/docs_rs_web/src/error.rs @@ -2,7 +2,7 @@ use crate::{ cache::CachePolicy, handlers::releases::Search, impl_axum_webpage, - page::templates::{RenderBrands, RenderSolid}, + page::templates::{AlertSeverityRender, RenderBrands, RenderSolid}, }; use anyhow::{Result, anyhow}; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 10584abb8..b39f9b27f 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -3,13 +3,14 @@ use crate::{ error::{AxumErrorPage, AxumResult}, extractors::{DbConnection, Path}, impl_axum_webpage, - page::templates::{RenderBrands, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderSolid, filters}, + page::warnings::WarningsCache, }; use askama::Template; use axum::{extract::Extension, http::StatusCode, response::IntoResponse}; use docs_rs_build_limits::Limits; use docs_rs_context::Context; -use docs_rs_database::service_config::{ConfigName, get_config}; +use docs_rs_database::service_config::{Abnormality, ConfigName, get_config}; use std::sync::Arc; #[derive(Template)] @@ -31,6 +32,19 @@ impl_axum_webpage!( cache_policy = |_| CachePolicy::ShortInCdnAndBrowser, ); +#[derive(Debug, Clone, PartialEq, Template)] +#[template(path = "core/about/status.html")] +struct AboutStatus { + abnormalities: Vec, +} + +impl_axum_webpage!( + AboutStatus, + // NOTE: potential future improvement: serve a special surrogate key, and + // purge that after we updated the local toolchain. + cache_policy = |_| CachePolicy::ShortInCdnAndBrowser, +); + pub(crate) async fn about_builds_handler( mut conn: DbConnection, Extension(context): Extension>, @@ -42,6 +56,14 @@ pub(crate) async fn about_builds_handler( }) } +pub(crate) async fn status_handler( + Extension(warnings_cache): Extension>, +) -> AxumResult { + Ok(AboutStatus { + abnormalities: warnings_cache.get().await.abnormalities, + }) +} + macro_rules! about_page { ($ty:ident, $template:literal) => { #[derive(Template)] @@ -96,6 +118,10 @@ mod tests { AxumResponseTestExt as _, AxumRouterTestExt, TestEnvironment, TestEnvironmentExt as _, }; use anyhow::Result; + use chrono::{TimeZone, Utc}; + use docs_rs_database::service_config::{AlertSeverity, AnchorId, set_config}; + use docs_rs_uri::EscapedURI; + use kuchikiki::traits::TendrilSink; #[tokio::test(flavor = "multi_thread")] async fn about_page() -> Result<()> { @@ -107,6 +133,7 @@ mod tests { let file_path = file?.path(); if file_path.extension() != Some(OsStr::new("html")) || file_path.file_stem() == Some(OsStr::new("index")) + || file_path.file_stem() == Some(OsStr::new("status")) { continue; } @@ -126,4 +153,102 @@ mod tests { web.assert_success("/about").await?; Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn about_status_page_renders_abnormality_details() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: Some(Utc.with_ymd_and_hms(2023, 1, 30, 19, 32, 33).unwrap()), + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let web = env.web_app().await; + let page = + kuchikiki::parse_html().one(web.assert_success("/-/status/").await?.text().await?); + + let body_text = page.text_contents(); + assert!(body_text.contains("Scheduled maintenance")); + assert!(body_text.contains("Planned maintenance is in progress.")); + assert!(body_text.contains("2023-01-30 19:32:33 UTC")); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn about_status_page_shows_no_abnormalities_when_clean() -> Result<()> { + let env = TestEnvironment::new().await?; + let web = env.web_app().await; + + let page = + kuchikiki::parse_html().one(web.assert_success("/-/status/").await?.text().await?); + + let body_text = page.text_contents(); + assert!(body_text.contains("No abnormalities detected currently.")); + assert_eq!( + page.select(".about h3").unwrap().count(), + 0, + "should not render any abnormality headings" + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn about_status_page_renders_html_explanation() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some( + "Planned maintenance is in progress. See details.".into(), + ), + start_time: None, + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let web = env.web_app().await; + let html = web.assert_success("/-/status/").await?.text().await?; + let page = kuchikiki::parse_html().one(html.clone()); + + // The tag should be rendered as an actual HTML element, not escaped. + assert!( + html.contains("in progress"), + "HTML in explanation should be rendered unescaped" + ); + + // The tag should be rendered as an actual link. + let link = page + .select(".about p a[href='/details']") + .unwrap() + .next() + .expect("explanation should contain a rendered link"); + assert!(link.text_contents().contains("details")); + + Ok(()) + } } diff --git a/crates/bin/docs_rs_web/src/handlers/build_details.rs b/crates/bin/docs_rs_web/src/handlers/build_details.rs index f8992619e..f9072a8c0 100644 --- a/crates/bin/docs_rs_web/src/handlers/build_details.rs +++ b/crates/bin/docs_rs_web/src/handlers/build_details.rs @@ -6,7 +6,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::Context as _; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/builds.rs b/crates/bin/docs_rs_web/src/handlers/builds.rs index b90ae9989..be986413e 100644 --- a/crates/bin/docs_rs_web/src/handlers/builds.rs +++ b/crates/bin/docs_rs_web/src/handlers/builds.rs @@ -6,7 +6,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::{Result, anyhow}; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/crate_details.rs b/crates/bin/docs_rs_web/src/handlers/crate_details.rs index 6ecfad115..3857718cd 100644 --- a/crates/bin/docs_rs_web/src/handlers/crate_details.rs +++ b/crates/bin/docs_rs_web/src/handlers/crate_details.rs @@ -8,7 +8,7 @@ use crate::{ impl_axum_webpage, match_release::{MatchedRelease, match_version}, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, utils::{get_correct_docsrs_style_file, licenses}, }; use anyhow::{Context, Result, anyhow}; diff --git a/crates/bin/docs_rs_web/src/handlers/features.rs b/crates/bin/docs_rs_web/src/handlers/features.rs index 4f7227e07..775182ba1 100644 --- a/crates/bin/docs_rs_web/src/handlers/features.rs +++ b/crates/bin/docs_rs_web/src/handlers/features.rs @@ -8,7 +8,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::anyhow; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/mod.rs b/crates/bin/docs_rs_web/src/handlers/mod.rs index c1ccd760e..29ab5e0db 100644 --- a/crates/bin/docs_rs_web/src/handlers/mod.rs +++ b/crates/bin/docs_rs_web/src/handlers/mod.rs @@ -15,7 +15,7 @@ pub(crate) mod status; use crate::Config; use crate::metrics::WebMetrics; use crate::middleware::{csp, security}; -use crate::page::{self, TemplateData}; +use crate::page::{self, TemplateData, warnings::WarningsCache}; use crate::{cache, routes}; use anyhow::{Context as _, Error, Result, anyhow, bail}; use axum::{ @@ -75,6 +75,8 @@ async fn apply_middleware( template_data: Option>, ) -> Result { let has_templates = template_data.is_some(); + let abnormality_cache = + Arc::new(WarningsCache::new(context.pool()?.clone(), context.build_queue()?.clone()).await); let web_metrics = Arc::new(WebMetrics::new(&context.meter_provider)); @@ -103,6 +105,7 @@ async fn apply_middleware( .layer(Extension(config.clone())) .layer(Extension(context.registry_api()?.clone())) .layer(Extension(context.storage()?.clone())) + .layer(Extension(abnormality_cache)) .layer(option_layer(template_data.map(Extension))) .layer(middleware::from_fn(csp::csp_middleware)) .layer(option_layer(has_templates.then_some(middleware::from_fn( @@ -231,9 +234,15 @@ mod tests { AxumResponseTestExt, AxumRouterTestExt, TestEnvironment, TestEnvironmentExt as _, async_wrapper, }; - use docs_rs_types::{DocCoverage, ReleaseId, Version}; + use docs_rs_config::AppConfig as _; + use docs_rs_database::service_config::{ + Abnormality, AlertSeverity, AnchorId, ConfigName, set_config, + }; + use docs_rs_types::{DocCoverage, KrateName, ReleaseId, Version, testing::V1}; + use docs_rs_uri::EscapedURI; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; + use std::str::FromStr; use test_case::test_case; async fn release(version: &str, env: &TestEnvironment) -> ReleaseId { @@ -262,6 +271,153 @@ mod tests { }); } + #[tokio::test(flavor = "multi_thread")] + async fn test_abnormality_is_not_rendered_when_unset() -> Result<()> { + let env = TestEnvironment::new().await?; + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + + assert_eq!(page.select("a.pure-menu-link.error").unwrap().count(), 0); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_manual_abnormality_renders_configured_link() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + // NOTE: abnormalities are cached inside the web-app, so set them + // before we fetch the web-app from the test-environments. + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, + severity: AlertSeverity::Warn, + }, + ) + .await?; + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let alert = page + .select("a.pure-menu-link.warn") + .unwrap() + .next() + .expect("missing abnormality"); + + assert_eq!( + alert.attributes.borrow().get("href"), + Some("/-/status/#manual") + ); + assert!(alert.text_contents().contains("Scheduled maintenance")); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_queue_abnormality_is_rendered_when_threshold_is_exceeded() -> Result<()> { + let mut queue_config = docs_rs_build_queue::Config::test_config()?; + queue_config.length_warning_threshold = 1; + let env = TestEnvironment::builder() + .build_queue_config(queue_config) + .build() + .await?; + let queue = env.build_queue()?.clone(); + + for idx in 0..2 { + let name = KrateName::from_str(&format!("queued-crate-{idx}"))?; + queue.add_crate(&name, &V1, 0, None).await?; + } + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let alert = page + .select("a.pure-menu-link.warn") + .unwrap() + .next() + .expect("missing queue alert"); + + assert_eq!( + alert.attributes.borrow().get("href"), + Some("/-/status/#queue-length") + ); + assert!(alert.text_contents().contains("long build queue")); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_manual_abnormality_wins_when_multiple_abnormalities_are_active() -> Result<()> { + let mut queue_config = docs_rs_build_queue::Config::test_config()?; + queue_config.length_warning_threshold = 1; + let env = TestEnvironment::builder() + .build_queue_config(queue_config) + .build() + .await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, + severity: AlertSeverity::Error, + }, + ) + .await?; + drop(conn); + + let queue = env.build_queue()?.clone(); + for idx in 0..2 { + let name = KrateName::from_str(&format!("queued-crate-{idx}"))?; + queue.add_crate(&name, &V1, 0, None).await?; + } + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let alert = page + .select("a.pure-menu-link.error") + .unwrap() + .next() + .expect("missing manual alert"); + + assert_eq!(alert.attributes.borrow().get("href"), Some("#")); + assert!(alert.text_contents().contains("Scheduled maintenance")); + let dropdown_links = page + .select("ul.pure-menu-children a.pure-menu-link") + .unwrap() + .map(|link| { + ( + link.attributes.borrow().get("href").unwrap().to_string(), + link.text_contents(), + ) + }) + .collect::>(); + + assert!(dropdown_links.iter().any(|(href, text)| { + href == "/-/status/#manual" && text.contains("Scheduled maintenance") + })); + assert!( + dropdown_links + .iter() + .any(|(href, text)| href == "/-/status/#queue-length" + && text.contains("long build queue")) + ); + Ok(()) + } + #[test] fn test_doc_coverage_for_crate_pages() { async_wrapper(|env| async move { diff --git a/crates/bin/docs_rs_web/src/handlers/releases.rs b/crates/bin/docs_rs_web/src/handlers/releases.rs index 2ea92f704..05a527130 100644 --- a/crates/bin/docs_rs_web/src/handlers/releases.rs +++ b/crates/bin/docs_rs_web/src/handlers/releases.rs @@ -9,7 +9,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metrics::WebMetrics, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::{Context as _, Result, anyhow}; use askama::Template; @@ -733,6 +733,7 @@ struct BuildQueuePage { rebuild_queue: Vec, in_progress_builds: Vec, expand_rebuild_queue: bool, + show_length_warning: bool, } impl_axum_webpage! { BuildQueuePage } @@ -798,6 +799,8 @@ pub(crate) async fn build_queue_handler( }) .collect::>(); + let show_length_warning = build_queue.build_queue_is_too_long(queue.iter()); + queue.retain_mut(|krate| { if krate.priority >= PRIORITY_CONTINUOUS { rebuild_queue.push(krate.clone()); @@ -817,6 +820,7 @@ pub(crate) async fn build_queue_handler( rebuild_queue, in_progress_builds, expand_rebuild_queue: params.expand.is_some(), + show_length_warning, }) } @@ -843,6 +847,7 @@ mod tests { use reqwest::StatusCode; use serde_json::json; use std::collections::HashSet; + use std::str::FromStr; use test_case::test_case; #[test] @@ -1819,6 +1824,7 @@ mod tests { .expect("missing heading") .any(|el| el.text_contents().contains("active CDN deployments")) ); + assert_eq!(empty.select(".warning").unwrap().count(), 0); let queue = env.build_queue()?; queue.add_crate(&FOO, &V1, 0, None).await?; @@ -1854,6 +1860,30 @@ mod tests { }); } + #[tokio::test(flavor = "multi_thread")] + async fn test_releases_queue_shows_length_warning_when_threshold_is_exceeded() -> Result<()> { + let env = TestEnvironment::new().await?; + let web = env.web_app().await; + let queue = env.build_queue()?; + + for idx in 0..1001 { + let name = KrateName::from_str(&format!("queued-crate-{idx}"))?; + queue.add_crate(&name, &V1, 0, None).await?; + } + + let page = kuchikiki::parse_html().one(web.get("/releases/queue").await?.text().await?); + let warning = page + .select(".warning") + .expect("missing warning container") + .next() + .expect("missing queue warning"); + + assert!(warning.text_contents().contains("build queue is too long")); + assert!(warning.text_contents().contains("The team is notified")); + + Ok(()) + } + #[test] fn test_releases_queue_in_progress() { async_wrapper(|env| async move { diff --git a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs index 22d9cf93e..6a5cbf849 100644 --- a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs +++ b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs @@ -16,10 +16,10 @@ use crate::{ middleware::csp::Csp, page::{ TemplateData, - templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, + warnings::{ActiveWarnings, WarningsCache}, }, - utils, - utils::licenses, + utils::{self, licenses}, }; use anyhow::{Context as _, anyhow}; use askama::Template; @@ -432,6 +432,7 @@ impl From for LimitedCrateDetails { pub struct RustdocPage { pub latest_path: EscapedURI, pub permalink_path: EscapedURI, + pub warnings: ActiveWarnings, // true if we are displaying the latest version of the crate, regardless // of whether the URL specifies a version number or the string "latest." pub is_latest_version: bool, @@ -555,6 +556,7 @@ pub(crate) async fn rustdoc_html_server_handler( Extension(storage): Extension>, Extension(config): Extension>, Extension(csp): Extension>, + Extension(warnings_cache): Extension>, RawQuery(original_query): RawQuery, if_none_match: Option>, mut conn: DbConnection, @@ -760,9 +762,11 @@ pub(crate) async fn rustdoc_html_server_handler( let current_target = params.doc_target_or_default().unwrap_or_default(); // Build the page of documentation, + let warnings = warnings_cache.get().await; let page = Arc::new(RustdocPage { latest_path, permalink_path, + warnings, is_latest_version, is_latest_url: params.req_version().is_latest(), is_prerelease, @@ -1070,6 +1074,9 @@ mod test { use anyhow::{Context, Result}; use chrono::{NaiveDate, Utc}; use docs_rs_cargo_metadata::Dependency; + use docs_rs_database::service_config::{ + Abnormality, AlertSeverity, AnchorId, ConfigName, set_config, + }; use docs_rs_registry_api::{CrateOwner, OwnerKind}; use docs_rs_rustdoc_json::{ RUSTDOC_JSON_COMPRESSION_ALGORITHMS, read_format_version_from_rustdoc_json, @@ -1079,6 +1086,7 @@ mod test { Version, testing::{KRATE, V2}, }; + use docs_rs_uri::EscapedURI; use docs_rs_uri::encode_url_path; use kuchikiki::traits::TendrilSink; use pretty_assertions::assert_eq; @@ -1135,6 +1143,60 @@ mod test { .with_context(|| anyhow::anyhow!("no redirect found for {}", path)) } + #[tokio::test(flavor = "multi_thread")] + async fn rustdoc_page_renders_abnormality() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + // NOTE: abnormalities are cached inside the web-app, so set them + // before we fetch the web-app from the test-environments. + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + env.fake_release() + .await + .name("dummy") + .version("0.1.0") + .rustdoc_file("dummy/index.html") + .create() + .await?; + + let web = env.web_app().await; + + let page = kuchikiki::parse_html().one( + web.assert_success("/dummy/0.1.0/dummy/") + .await? + .text() + .await?, + ); + let alert = page + .select("a.pure-menu-link.warn") + .expect("invalid selector") + .next() + .context("missing abnormality on rustdoc page")?; + + assert_eq!( + alert.attributes.borrow().get("href"), + Some("/-/status/#manual") + ); + assert!(alert.text_contents().contains("Scheduled maintenance")); + Ok(()) + } + #[test_case(true)] #[test_case(false)] // https://github.com/rust-lang/docs.rs/issues/2313 diff --git a/crates/bin/docs_rs_web/src/handlers/source.rs b/crates/bin/docs_rs_web/src/handlers/source.rs index be702cf76..687f7a8ab 100644 --- a/crates/bin/docs_rs_web/src/handlers/source.rs +++ b/crates/bin/docs_rs_web/src/handlers/source.rs @@ -10,7 +10,7 @@ use crate::{ impl_axum_webpage, match_release::match_version, metadata::MetaData, - page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, + page::templates::{AlertSeverityRender, RenderBrands, RenderRegular, RenderSolid, filters}, }; use anyhow::{Context as _, Result}; diff --git a/crates/bin/docs_rs_web/src/lib.rs b/crates/bin/docs_rs_web/src/lib.rs index 4ddbb8463..edd28d7c3 100644 --- a/crates/bin/docs_rs_web/src/lib.rs +++ b/crates/bin/docs_rs_web/src/lib.rs @@ -28,16 +28,3 @@ pub use docs_rs_build_limits::DEFAULT_MAX_TARGETS; pub use docs_rs_utils::{APP_USER_AGENT, BUILD_VERSION, RUSTDOC_STATIC_STORAGE_PREFIX}; pub use font_awesome_as_a_crate::icons; pub use handlers::run_web_server; - -use page::GlobalAlert; - -// Warning message shown in the navigation bar of every page. Set to `None` to hide it. -pub(crate) static GLOBAL_ALERT: Option = None; -/* -pub(crate) static GLOBAL_ALERT: Option = Some(GlobalAlert { - url: "https://blog.rust-lang.org/2019/09/18/upcoming-docsrs-changes.html", - text: "Upcoming docs.rs breaking changes!", - css_class: "error", - fa_icon: "exclamation-triangle", -}); -*/ diff --git a/crates/bin/docs_rs_web/src/page/mod.rs b/crates/bin/docs_rs_web/src/page/mod.rs index 8a0146f98..4c9c20b83 100644 --- a/crates/bin/docs_rs_web/src/page/mod.rs +++ b/crates/bin/docs_rs_web/src/page/mod.rs @@ -1,12 +1,6 @@ pub(crate) mod templates; +pub(crate) mod warnings; pub(crate) mod web_page; pub(crate) use templates::TemplateData; - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(crate) struct GlobalAlert { - pub(crate) url: &'static str, - pub(crate) text: &'static str, - pub(crate) css_class: &'static str, - pub(crate) fa_icon: crate::icons::IconTriangleExclamation, -} +pub(crate) use warnings::ActiveWarnings; diff --git a/crates/bin/docs_rs_web/src/page/templates.rs b/crates/bin/docs_rs_web/src/page/templates.rs index c570920f6..20e54f049 100644 --- a/crates/bin/docs_rs_web/src/page/templates.rs +++ b/crates/bin/docs_rs_web/src/page/templates.rs @@ -1,6 +1,7 @@ use crate::handlers::rustdoc::RustdocPage; use anyhow::{Context as _, Result}; use askama::Template; +use docs_rs_database::service_config::AlertSeverity; use std::sync::Arc; use tracing::trace; @@ -269,6 +270,34 @@ impl RenderBrands for T { } } +/// how to render the severity for an abnormality +pub(crate) trait AlertSeverityRender { + fn css_class(&self) -> &'static str; + fn render_icon_solid(&self, fw: bool, spin: bool, extra: &str) + -> askama::filters::Safe; +} + +impl AlertSeverityRender for AlertSeverity { + fn css_class(&self) -> &'static str { + match self { + Self::Warn => "warn", + Self::Error => "error", + } + } + + fn render_icon_solid( + &self, + fw: bool, + spin: bool, + extra: &str, + ) -> askama::filters::Safe { + match self { + Self::Warn => crate::icons::IconTriangleExclamation.render_solid(fw, spin, extra), + Self::Error => crate::icons::IconCircleXmark.render_solid(fw, spin, extra), + } + } +} + fn render( icon_kind: &str, css_class: &str, diff --git a/crates/bin/docs_rs_web/src/page/warnings.rs b/crates/bin/docs_rs_web/src/page/warnings.rs new file mode 100644 index 000000000..5824a7564 --- /dev/null +++ b/crates/bin/docs_rs_web/src/page/warnings.rs @@ -0,0 +1,341 @@ +use anyhow::{Context as _, Result}; +use chrono::Utc; +use docs_rs_build_queue::AsyncBuildQueue; +use docs_rs_database::{ + Pool, + service_config::{Abnormality, ConfigName, get_config}, +}; +use serde::Serialize; +use std::{sync::Arc, time::Duration}; +use tokio::{ + sync::RwLock, + task::JoinHandle, + time::{MissedTickBehavior, interval}, +}; +use tracing::{debug, error}; + +pub(crate) type ActiveAbnormalities = Vec; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ActiveWarnings { + pub(crate) abnormalities: ActiveAbnormalities, +} + +/// cache for warning items to be shown on mane pages. +/// * abnormalities (long build queue, cpu usage / response times, ...) +/// * later alerts / notifications (to be discarded by the user) +#[derive(Debug)] +pub(crate) struct WarningsCache { + background_task: JoinHandle<()>, + state: Arc>, +} + +impl WarningsCache { + const TTL: Duration = Duration::from_secs(300); // 5 minutes + + pub(crate) async fn new(pool: Pool, build_queue: Arc) -> Self { + Self::new_with_ttl(pool, build_queue, Self::TTL).await + } + + async fn new_with_ttl(pool: Pool, build_queue: Arc, ttl: Duration) -> Self { + async fn load_abnormalities( + pool: &Pool, + build_queue: &AsyncBuildQueue, + previous_snapshot: &[Abnormality], + ) -> Option { + match WarningsCache::load_abnormalities(pool, build_queue, previous_snapshot).await { + Ok(snapshot) => Some(snapshot), + Err(err) => { + error!(?err, "failed to load abnormalities"); + None + } + } + } + + let initial_abnormalities = load_abnormalities(&pool, &build_queue, &[]) + .await + .unwrap_or_default(); + + let state = Arc::new(RwLock::new(ActiveWarnings { + abnormalities: initial_abnormalities, + })); + let refresh_state = Arc::clone(&state); + + let background_task = tokio::spawn(async move { + let mut refresh_interval = interval(ttl); + refresh_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Consume the immediate tick because we already did the initial load. + refresh_interval.tick().await; + + loop { + refresh_interval.tick().await; + + debug!("loading alerts snapshot"); + let previous_abnormalities = refresh_state.read().await.abnormalities.clone(); + + if let Some(abnormalities) = + load_abnormalities(&pool, &build_queue, &previous_abnormalities).await + { + let mut state = refresh_state.write().await; + state.abnormalities = abnormalities; + } + } + }); + + Self { + state, + background_task, + } + } + + async fn load_abnormalities( + pool: &Pool, + build_queue: &AsyncBuildQueue, + previous_abnormalities: &[Abnormality], + ) -> Result { + let mut conn = pool + .get_async() + .await + .context("failed to get DB connection for alerts")?; + + let mut active_abnormalities = ActiveAbnormalities::new(); + + if let Some(abnormality) = get_config::(&mut conn, ConfigName::Abnormality) + .await + .context("failed to load manual abnormality from config")? + { + active_abnormalities.push(abnormality); + } + + let mut queue_abnormalities = build_queue + .gather_alerts() + .await + .context("failed to load build queue abnormalities")?; + for abnormality in &mut queue_abnormalities { + Self::assign_start_time(abnormality, previous_abnormalities); + } + active_abnormalities.extend(queue_abnormalities); + + Ok(active_abnormalities) + } + + fn same_abnormality(left: &Abnormality, right: &Abnormality) -> bool { + left.anchor_id == right.anchor_id + } + + fn assign_start_time(abnormality: &mut Abnormality, previous_snapshot: &[Abnormality]) { + if abnormality.start_time.is_some() { + return; + } + + abnormality.start_time = previous_snapshot + .iter() + .find(|previous| Self::same_abnormality(previous, abnormality)) + .and_then(|previous| previous.start_time) + .or_else(|| Some(Utc::now())); + } + + pub(crate) async fn get(&self) -> ActiveWarnings { + self.state.read().await.clone() + } +} + +impl Drop for WarningsCache { + fn drop(&mut self) { + self.background_task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::TestEnvironment; + use anyhow::Result; + use docs_rs_config::AppConfig as _; + use docs_rs_database::service_config::{AlertSeverity, AnchorId, set_config}; + use docs_rs_uri::EscapedURI; + use tokio::time::sleep; + + #[tokio::test(flavor = "multi_thread")] + async fn cache_loads_immediately_and_keeps_previous_value_on_reload_failure() -> Result<()> { + let env = TestEnvironment::new().await?; + + let mut conn = env.async_conn().await?; + set_config( + &mut conn, + ConfigName::Abnormality, + Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/maintenance" + .parse::() + .unwrap(), + text: "Scheduled maintenance".into(), + explanation: Some("Planned maintenance is in progress.".into()), + start_time: None, + severity: AlertSeverity::Warn, + }, + ) + .await?; + drop(conn); + + let cache = WarningsCache::new_with_ttl( + env.pool()?.clone(), + env.build_queue()?.clone(), + Duration::from_millis(25), + ) + .await; + + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .map(|alert| alert.text.as_str()), + Some("Scheduled maintenance") + ); + + let mut conn = env.async_conn().await?; + sqlx::query!( + "UPDATE config SET value = $2 WHERE name = $1", + "abnormality", + serde_json::json!({ + "url": 1, + "text": false + }), + ) + .execute(&mut *conn) + .await?; + drop(conn); + + sleep(Duration::from_millis(75)).await; + + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .map(|abnormality| abnormality.text.as_str()), + Some("Scheduled maintenance") + ); + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .map(|abnormality| &abnormality.anchor_id), + Some(&AnchorId::Manual) + ); + assert_eq!( + cache + .get() + .await + .abnormalities + .first() + .and_then(|abnormality| abnormality.start_time), + None + ); + + Ok(()) + } + + #[test] + fn same_abnormality_uses_anchor_id_only() { + let left = Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/one".parse::().unwrap(), + text: "first text".into(), + explanation: Some("first explanation".into()), + start_time: None, + severity: AlertSeverity::Warn, + }; + let right = Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com/two".parse::().unwrap(), + text: "second text".into(), + explanation: None, + start_time: None, + severity: AlertSeverity::Error, + }; + + assert!(WarningsCache::same_abnormality(&left, &right)); + } + + #[test] + fn same_abnormality_returns_false_for_different_anchor_ids() { + let left = Abnormality { + anchor_id: AnchorId::Manual, + url: "https://example.com".parse::().unwrap(), + text: "same text".into(), + explanation: Some("same explanation".into()), + start_time: None, + severity: AlertSeverity::Warn, + }; + let right = Abnormality { + anchor_id: AnchorId::QueueLength, + url: "https://example.com".parse::().unwrap(), + text: "same text".into(), + explanation: Some("same explanation".into()), + start_time: None, + severity: AlertSeverity::Warn, + }; + + assert!(!WarningsCache::same_abnormality(&left, &right)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn cache_preserves_queue_start_time_across_refreshes() -> Result<()> { + let mut queue_config = docs_rs_build_queue::Config::test_config()?; + queue_config.length_warning_threshold = 1; + let env = crate::testing::TestEnvironment::builder() + .build_queue_config(queue_config) + .build() + .await?; + + let queue = env.build_queue()?.clone(); + for idx in 0..2 { + let name = format!("queued-crate-{idx}").parse::()?; + queue + .add_crate(&name, &docs_rs_types::Version::parse("1.0.0")?, 0, None) + .await?; + } + + let cache = WarningsCache::new_with_ttl( + env.pool()?.clone(), + env.build_queue()?.clone(), + Duration::from_millis(25), + ) + .await; + + let first_snapshot = cache.get().await; + let first_start_time = first_snapshot + .abnormalities + .iter() + .find(|a| a.anchor_id == AnchorId::QueueLength) + .expect("missing queue-length abnormality on first load") + .start_time + .expect("queue-length abnormality should have a start_time"); + + // Wait for at least one cache refresh cycle. + sleep(Duration::from_millis(75)).await; + + let second_snapshot = cache.get().await; + let second_start_time = second_snapshot + .abnormalities + .iter() + .find(|a| a.anchor_id == AnchorId::QueueLength) + .expect("missing queue-length abnormality after refresh") + .start_time + .expect("queue-length abnormality should still have a start_time"); + + assert_eq!( + first_start_time, second_start_time, + "start_time should be preserved across cache refreshes" + ); + + Ok(()) + } +} diff --git a/crates/bin/docs_rs_web/src/page/web_page.rs b/crates/bin/docs_rs_web/src/page/web_page.rs index 3946af94e..2e457ba70 100644 --- a/crates/bin/docs_rs_web/src/page/web_page.rs +++ b/crates/bin/docs_rs_web/src/page/web_page.rs @@ -1,4 +1,11 @@ -use crate::{error::AxumNope, middleware::csp::Csp, page::templates::TemplateData}; +use crate::{ + error::AxumNope, + middleware::csp::Csp, + page::{ + templates::TemplateData, + warnings::{ActiveWarnings, WarningsCache}, + }, +}; use axum::{ body::Body, extract::Request as AxumRequest, @@ -9,8 +16,12 @@ use futures_util::future::{BoxFuture, FutureExt}; use http::header::CONTENT_LENGTH; use std::sync::Arc; -pub(crate) trait AddCspNonce: IntoResponse { - fn render_with_csp_nonce(&mut self, csp_nonce: String) -> askama::Result; +pub(crate) trait AddTemplateValues: IntoResponse { + fn render_with_template_values( + &mut self, + csp_nonce: String, + warnings: ActiveWarnings, + ) -> askama::Result; } #[macro_export] @@ -24,9 +35,18 @@ macro_rules! impl_axum_webpage { $(, cpu_intensive_rendering = $cpu_intensive_rendering:expr)? $(,)? ) => { - impl $crate::page::web_page::AddCspNonce for $page { - fn render_with_csp_nonce(&mut self, csp_nonce: String) -> askama::Result { - let values: (&str, &dyn std::any::Any) = ("csp_nonce", &csp_nonce); + impl $crate::page::web_page::AddTemplateValues for $page { + fn render_with_template_values( + &mut self, + csp_nonce: String, + warnings: $crate::page::ActiveWarnings, + ) -> askama::Result { + let values: std::collections::HashMap<&str, &dyn std::any::Any> = [ + ("csp_nonce", &csp_nonce as &dyn std::any::Any), + ("warnings", &warnings as &dyn std::any::Any), + ] + .into_iter() + .collect(); self.render_with_values(&values) } } @@ -95,7 +115,7 @@ macro_rules! impl_axum_webpage { /// the context. #[derive(Clone)] pub(crate) struct DelayedTemplateRender { - pub template: Arc>, + pub template: Arc>, pub cpu_intensive_rendering: bool, } @@ -103,6 +123,7 @@ fn render_response( mut response: AxumResponse, templates: Arc, csp_nonce: String, + warnings: ActiveWarnings, ) -> BoxFuture<'static, AxumResponse> { async move { if let Some(render) = response.extensions_mut().remove::() { @@ -112,18 +133,19 @@ fn render_response( } = render; let mut template = Arc::into_inner(template).unwrap(); let csp_nonce_clone = csp_nonce.clone(); + let warnings_clone = warnings.clone(); let result: Result = if cpu_intensive_rendering { templates .render_in_threadpool(move || { template - .render_with_csp_nonce(csp_nonce_clone) + .render_with_template_values(csp_nonce_clone, warnings_clone) .map_err(|err| err.into()) }) .await } else { template - .render_with_csp_nonce(csp_nonce_clone) + .render_with_template_values(csp_nonce_clone, warnings_clone) .map_err(|err| err.into()) }; @@ -138,6 +160,7 @@ fn render_response( AxumNope::InternalError(err).into_response(), templates, csp_nonce, + warnings, ) .await; } @@ -170,7 +193,14 @@ pub(crate) async fn render_templates_middleware(req: AxumRequest, next: Next) -> .nonce() .to_owned(); + let warnings = req + .extensions() + .get::>() + .expect("warnings cache request extension not found") + .get() + .await; + let response = next.run(req).await; - render_response(response, templates, csp_nonce).await + render_response(response, templates, csp_nonce, warnings).await } diff --git a/crates/bin/docs_rs_web/src/routes.rs b/crates/bin/docs_rs_web/src/routes.rs index 8fef55986..7638f2aea 100644 --- a/crates/bin/docs_rs_web/src/routes.rs +++ b/crates/bin/docs_rs_web/src/routes.rs @@ -143,6 +143,7 @@ pub(crate) fn build_axum_routes() -> Result { "/-/sitemap/{letter}/sitemap.xml", get_internal(sitemap::sitemap_handler), ) + .route_with_tsr("/-/status/", get_internal(about::status_handler)) .route_with_tsr("/about/builds", get_internal(about::about_builds_handler)) .route_with_tsr("/about", get_internal(about::about_handler)) .route_with_tsr("/about/{subpage}", get_internal(about::about_handler)) diff --git a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs index 307f10a58..d177a595a 100644 --- a/crates/bin/docs_rs_web/src/utils/html_rewrite.rs +++ b/crates/bin/docs_rs_web/src/utils/html_rewrite.rs @@ -12,7 +12,7 @@ use async_stream::stream; use axum::body::Bytes; use futures_util::{Stream, StreamExt as _}; use lol_html::{element, errors::RewritingError}; -use std::sync::Arc; +use std::{any::Any, collections::HashMap, sync::Arc}; use tokio::{io::AsyncRead, task::JoinHandle}; use tokio_util::io::ReaderStream; use tracing::{Span, error, instrument}; @@ -69,7 +69,9 @@ where let head_html = Head::new(&data).render().unwrap(); let vendored_html = Vendored.render().unwrap(); let body_html = Body.render().unwrap(); - let topbar_html = data.render().unwrap(); + let values: HashMap<&str, &dyn Any> = + HashMap::from_iter([("warnings", &data.warnings as &dyn Any)]); + let topbar_html = data.render_with_values(&values).unwrap(); // Before: ... rustdoc content ... // After: diff --git a/crates/bin/docs_rs_web/templates/core/about/status.html b/crates/bin/docs_rs_web/templates/core/about/status.html new file mode 100644 index 000000000..6d07ff957 --- /dev/null +++ b/crates/bin/docs_rs_web/templates/core/about/status.html @@ -0,0 +1,31 @@ +{% extends "about-base.html" %} + +{%- block title -%} Docs.rs status {%- endblock title -%} + +{%- block body -%} +

Docs.rs status

+ +
+{%- endblock body %} diff --git a/crates/bin/docs_rs_web/templates/header/abnormalities.html b/crates/bin/docs_rs_web/templates/header/abnormalities.html new file mode 100644 index 000000000..5502dc892 --- /dev/null +++ b/crates/bin/docs_rs_web/templates/header/abnormalities.html @@ -0,0 +1,27 @@ +{%- let abnormalities = askama::get_value::("warnings").unwrap().abnormalities -%} + +{%- if abnormalities.len() == 1 -%} +
  • + {% let abnormality = abnormalities.first().unwrap() %} + + {{- abnormality.severity.render_icon_solid(false, false, "") }} + {{ abnormality.text -}} + +
  • +{%- elif abnormalities.len() >= 2 -%} + {% let first = abnormalities.first().unwrap() %} +
  • + + {{- first.severity.render_icon_solid(false, false, "") }} {{ first.text -}} + + +
  • +{% endif %} diff --git a/crates/bin/docs_rs_web/templates/header/global_alert.html b/crates/bin/docs_rs_web/templates/header/global_alert.html deleted file mode 100644 index fcca2535e..000000000 --- a/crates/bin/docs_rs_web/templates/header/global_alert.html +++ /dev/null @@ -1,11 +0,0 @@ -{# Get the current global alert #} - -{# If there is a global alert, render it #} -{%- if let Some(global_alert) = crate::GLOBAL_ALERT -%} -
  • - - {{- global_alert.fa_icon.render_solid(false, false, "") }} - {{ global_alert.text -}} - -
  • -{% endif %} diff --git a/crates/bin/docs_rs_web/templates/header/topbar_end.html b/crates/bin/docs_rs_web/templates/header/topbar_end.html index 6be3a52b5..722d9695d 100644 --- a/crates/bin/docs_rs_web/templates/header/topbar_end.html +++ b/crates/bin/docs_rs_web/templates/header/topbar_end.html @@ -1,8 +1,8 @@ {%- import "macros.html" as macros -%}
    - {# The global alert, if there is one #} - {% include "header/global_alert.html" -%} + {# The current abnormalities, if there are any #} + {% include "header/abnormalities.html" -%}
      {# diff --git a/crates/bin/docs_rs_web/templates/releases/build_queue.html b/crates/bin/docs_rs_web/templates/releases/build_queue.html index beb11030e..fa44caf25 100644 --- a/crates/bin/docs_rs_web/templates/releases/build_queue.html +++ b/crates/bin/docs_rs_web/templates/releases/build_queue.html @@ -18,6 +18,15 @@ {%- block body -%}
      + + {%- if show_length_warning %} +
      + The docs.rs build queue is too long.
      + Building your crate might take longer, up to a couple of days.
      + The team is notified. +
      + {%- endif %} +
      Currently being built diff --git a/crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_build_limits/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_build_queue/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_build_queue/Cargo.toml b/crates/lib/docs_rs_build_queue/Cargo.toml index 2bd5da10c..f1ec57d57 100644 --- a/crates/lib/docs_rs_build_queue/Cargo.toml +++ b/crates/lib/docs_rs_build_queue/Cargo.toml @@ -21,6 +21,7 @@ docs_rs_env_vars = { path = "../docs_rs_env_vars" } docs_rs_opentelemetry = { path = "../docs_rs_opentelemetry" } docs_rs_repository_stats = { path = "../docs_rs_repository_stats" } docs_rs_types = { path = "../docs_rs_types" } +docs_rs_uri = { path = "../docs_rs_uri" } docs_rs_utils = { path = "../docs_rs_utils" } futures-util = { workspace = true } opentelemetry = { workspace = true } diff --git a/crates/lib/docs_rs_build_queue/src/config.rs b/crates/lib/docs_rs_build_queue/src/config.rs index 6438589aa..fbbe5855e 100644 --- a/crates/lib/docs_rs_build_queue/src/config.rs +++ b/crates/lib/docs_rs_build_queue/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { pub build_attempts: u16, pub deprioritize_workspace_size: u16, pub delay_between_build_attempts: Duration, + pub length_warning_threshold: usize, } impl Default for Config { @@ -16,6 +17,7 @@ impl Default for Config { build_attempts: 5, deprioritize_workspace_size: 20, delay_between_build_attempts: Duration::from_secs(60), + length_warning_threshold: 1000, } } } @@ -36,6 +38,10 @@ impl AppConfig for Config { config.deprioritize_workspace_size = size; } + if let Some(length) = maybe_env::("DOCSRS_QUEUE_LENGTH_WARNING_THRESHOLD")? { + config.length_warning_threshold = length; + } + Ok(config) } } diff --git a/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs b/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs index 245cf5c53..990e8661b 100644 --- a/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs +++ b/crates/lib/docs_rs_build_queue/src/queue/non_blocking.rs @@ -1,12 +1,16 @@ -use crate::{Config, PRIORITY_DEFAULT, PRIORITY_DEPRIORITIZED, QueuedCrate, metrics}; -use anyhow::Result; +use crate::{ + Config, PRIORITY_DEFAULT, PRIORITY_DEPRIORITIZED, PRIORITY_MANUAL_FROM_CRATES_IO, QueuedCrate, + metrics, +}; +use anyhow::{Context as _, Result}; use docs_rs_database::{ Pool, - service_config::{ConfigName, get_config, set_config}, + service_config::{Abnormality, AlertSeverity, AnchorId, ConfigName, get_config, set_config}, }; use docs_rs_opentelemetry::AnyMeterProvider; use docs_rs_repository_stats::workspaces; use docs_rs_types::{KrateName, Version}; +use docs_rs_uri::EscapedURI; use futures_util::TryStreamExt as _; use std::{ collections::{HashMap, HashSet}, @@ -260,6 +264,47 @@ impl AsyncBuildQueue { Ok(()) } + + pub fn build_queue_is_too_long<'a>( + &self, + queued_crates: impl Iterator, + ) -> bool { + queued_crates + .filter(|qc| qc.priority < PRIORITY_MANUAL_FROM_CRATES_IO) + .count() + > self.config.length_warning_threshold + } + + /// fetch the current queue alerts + pub async fn gather_alerts(&self) -> Result> { + let queue_pending_count = self + .pending_count_by_priority() + .await + .context("failed to fetch queue length for alerts")? + .into_iter() + .filter_map(|(prio, amount)| (prio < PRIORITY_MANUAL_FROM_CRATES_IO).then_some(amount)) + .sum::(); + + let mut alerts = Vec::with_capacity(1); + + if queue_pending_count > self.config.length_warning_threshold { + alerts.push(Abnormality { + anchor_id: AnchorId::QueueLength, + url: EscapedURI::from_path("/releases/queue"), + text: "long build queue".into(), + explanation: Some( + format!( + "The build queue currently contains more than {} crates, so it might take a while before new published crates get documented.", + self.config.length_warning_threshold, + ) + ), + start_time: None, + severity: AlertSeverity::Warn, + }); + } + + Ok(alerts) + } } /// Locking functions. @@ -582,4 +627,51 @@ mod tests { Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn test_length_warning_threshold_boundary() -> Result<()> { + let mut config = Config::from_environment()?; + config.length_warning_threshold = 1; + let env = test_queue_with_config(config).await?; + let queue = env.queue; + + queue.add_crate(&FOO, &V1, 0, None).await?; + + assert!(!queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); + assert!(queue.gather_alerts().await?.is_empty()); + + queue.add_crate(&BAR, &V1, 0, None).await?; + + assert!(queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); + assert_eq!( + queue.gather_alerts().await?, + vec![Abnormality { + anchor_id: AnchorId::QueueLength, + url: EscapedURI::from_path("/releases/queue"), + text: "long build queue".into(), + explanation: Some("The build queue currently contains more than 1 crates, so it might take a while before new published crates get documented.".into()), + start_time: None, + severity: AlertSeverity::Warn, + }] + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_public_alert_ignores_manual_crates() -> Result<()> { + let mut config = Config::from_environment()?; + config.length_warning_threshold = 0; + let env = test_queue_with_config(config).await?; + let queue = env.queue; + + queue + .add_crate(&FOO, &V1, PRIORITY_MANUAL_FROM_CRATES_IO, None) + .await?; + + assert!(!queue.build_queue_is_too_long(queue.queued_crates().await?.iter())); + assert!(queue.gather_alerts().await?.is_empty()); + + Ok(()) + } } diff --git a/crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_context/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs b/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs index 4b96d6724..98583d1f5 100644 --- a/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs +++ b/crates/lib/docs_rs_context/src/testing/test_env/non_blocking.rs @@ -1,6 +1,7 @@ use crate::Context; use anyhow::Result; use bon::bon; +use docs_rs_build_queue::AsyncBuildQueue; use docs_rs_config::AppConfig; use docs_rs_database::{AsyncPoolClient, Config as DatabaseConfig, testing::TestDatabase}; use docs_rs_fastly::Cdn; @@ -43,6 +44,7 @@ impl TestEnvironment { config: Option, registry_api_config: Option, storage_config: Option, + build_queue_config: Option, ) -> Result { docs_rs_logging::testing::init(); @@ -75,6 +77,18 @@ impl TestEnvironment { let test_storage = TestStorage::from_config(storage_config.clone(), metrics.provider()).await?; + let build_queue_config = Arc::new(if let Some(config) = build_queue_config { + config + } else { + docs_rs_build_queue::Config::from_environment()? + }); + + let build_queue = Arc::new(AsyncBuildQueue::new( + db.pool().clone(), + build_queue_config.clone(), + metrics.provider(), + )); + Ok(Self { config: app_config, context: Context::builder() @@ -83,7 +97,7 @@ impl TestEnvironment { .meter_provider(metrics.provider().clone()) .pool(db_config.into(), db.pool().clone()) .storage(storage_config.clone(), test_storage.storage()) - .with_build_queue()? + .build_queue(build_queue_config, build_queue) .registry_api(registry_api_config, registry_api.into()) .with_repository_stats()? .maybe_cdn( diff --git a/crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_database/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_database/Cargo.toml b/crates/lib/docs_rs_database/Cargo.toml index 3076eeab0..2193806dd 100644 --- a/crates/lib/docs_rs_database/Cargo.toml +++ b/crates/lib/docs_rs_database/Cargo.toml @@ -17,6 +17,7 @@ docs_rs_env_vars = { path = "../docs_rs_env_vars" } docs_rs_opentelemetry = { path = "../docs_rs_opentelemetry" } docs_rs_registry_api = { path = "../docs_rs_registry_api" } docs_rs_types = { path = "../docs_rs_types" } +docs_rs_uri = { path = "../docs_rs_uri" } docs_rs_utils = { path = "../docs_rs_utils" } futures-util = { workspace = true } hex = "0.4.3" diff --git a/crates/lib/docs_rs_database/src/service_config/abnormalities.rs b/crates/lib/docs_rs_database/src/service_config/abnormalities.rs new file mode 100644 index 000000000..8a116e220 --- /dev/null +++ b/crates/lib/docs_rs_database/src/service_config/abnormalities.rs @@ -0,0 +1,100 @@ +use anyhow::{Result, bail}; +use chrono::{DateTime, Utc}; +use docs_rs_uri::EscapedURI; +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; +use strum::VariantArray; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AnchorId { + Manual, + QueueLength, +} + +impl AnchorId { + pub fn as_str(&self) -> &'static str { + match self { + Self::Manual => "manual", + Self::QueueLength => "queue-length", + } + } +} + +impl fmt::Display for AnchorId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for AnchorId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +/// alert severity with icon. +/// Used by abnormalities & global alerts +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, VariantArray)] +pub enum AlertSeverity { + #[default] + Warn, + Error, +} + +impl fmt::Display for AlertSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Warn => f.write_str("warn"), + Self::Error => f.write_str("error"), + } + } +} + +impl FromStr for AlertSeverity { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("warn") { + Ok(Self::Warn) + } else if s.eq_ignore_ascii_case("error") { + Ok(Self::Error) + } else { + bail!("invalid severity: {s}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Abnormality { + pub anchor_id: AnchorId, + pub url: EscapedURI, + pub text: String, + /// explanation to be shown on the status page, can be HTML + #[serde(default)] + pub explanation: Option, + #[serde(default)] + pub start_time: Option>, + #[serde(default)] + pub severity: AlertSeverity, +} + +impl Abnormality { + pub fn topbar_url(&self) -> EscapedURI { + EscapedURI::from_path("/-/status/").with_fragment(self.anchor_id.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_is_from_str_for_all_variants() { + for severity in AlertSeverity::VARIANTS { + assert_eq!( + *severity, + severity.to_string().parse::().unwrap() + ); + } + } +} diff --git a/crates/lib/docs_rs_database/src/service_config.rs b/crates/lib/docs_rs_database/src/service_config/mod.rs similarity index 62% rename from crates/lib/docs_rs_database/src/service_config.rs rename to crates/lib/docs_rs_database/src/service_config/mod.rs index 93ac86c2e..d0cd5c98e 100644 --- a/crates/lib/docs_rs_database/src/service_config.rs +++ b/crates/lib/docs_rs_database/src/service_config/mod.rs @@ -1,6 +1,10 @@ +mod abnormalities; + use anyhow::Result; use serde::{Serialize, de::DeserializeOwned}; +pub use abnormalities::{Abnormality, AlertSeverity, AnchorId}; + #[derive(strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub enum ConfigName { @@ -8,6 +12,7 @@ pub enum ConfigName { LastSeenIndexReference, QueueLocked, Toolchain, + Abnormality, } pub async fn set_config( @@ -28,6 +33,14 @@ pub async fn set_config( Ok(()) } +pub async fn remove_config(conn: &mut sqlx::PgConnection, name: ConfigName) -> anyhow::Result<()> { + let name: &'static str = name.into(); + sqlx::query!("DELETE FROM config WHERE name = $1;", name) + .execute(conn) + .await?; + Ok(()) +} + pub async fn get_config(conn: &mut sqlx::PgConnection, name: ConfigName) -> Result> where T: DeserializeOwned, @@ -56,6 +69,7 @@ mod tests { #[test_case(ConfigName::RustcVersion, "rustc_version")] #[test_case(ConfigName::QueueLocked, "queue_locked")] #[test_case(ConfigName::LastSeenIndexReference, "last_seen_index_reference")] + #[test_case(ConfigName::Abnormality, "abnormality")] fn test_configname_variants(variant: ConfigName, expected: &'static str) { let name: &'static str = variant.into(); assert_eq!(name, expected); @@ -107,4 +121,52 @@ mod tests { ); Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn test_remove_existing_config() -> anyhow::Result<()> { + let test_metrics = TestMetrics::new(); + let db = TestDatabase::new(&Config::test_config()?, test_metrics.provider()).await?; + + let mut conn = db.async_conn().await?; + set_config( + &mut conn, + ConfigName::RustcVersion, + Value::String("some value".into()), + ) + .await?; + + assert_eq!( + get_config(&mut conn, ConfigName::RustcVersion).await?, + Some("some value".to_string()) + ); + + remove_config(&mut conn, ConfigName::RustcVersion).await?; + + assert!( + get_config::(&mut conn, ConfigName::RustcVersion) + .await? + .is_none() + ); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_remove_missing_config_is_noop() -> anyhow::Result<()> { + let test_metrics = TestMetrics::new(); + let db = TestDatabase::new(&Config::test_config()?, test_metrics.provider()).await?; + + let mut conn = db.async_conn().await?; + sqlx::query!("DELETE FROM config") + .execute(&mut *conn) + .await?; + + remove_config(&mut conn, ConfigName::RustcVersion).await?; + + assert!( + get_config::(&mut conn, ConfigName::RustcVersion) + .await? + .is_none() + ); + Ok(()) + } } diff --git a/crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_repository_stats/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +} diff --git a/crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json b/crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json new file mode 100644 index 000000000..c177dd6be --- /dev/null +++ b/crates/lib/docs_rs_test_fakes/.sqlx/query-671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM config WHERE name = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "671354f90d42d2d6ce67b952474560588c0187a00f62f3525635182c482eaec3" +}