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 b3473484d..ab9d1ae92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1917,6 +1917,7 @@ dependencies = [ "docs_rs_storage", "docs_rs_test_fakes", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "pretty_assertions", @@ -1960,6 +1961,7 @@ dependencies = [ "docs_rs_storage", "docs_rs_test_fakes", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "opentelemetry", @@ -2062,6 +2064,7 @@ dependencies = [ "docs_rs_opentelemetry", "docs_rs_registry_api", "docs_rs_types", + "docs_rs_uri", "docs_rs_utils", "futures-util", "hex", @@ -2435,6 +2438,7 @@ dependencies = [ "docs_rs_context", "docs_rs_database", "docs_rs_env_vars", + "docs_rs_fastly", "docs_rs_headers", "docs_rs_logging", "docs_rs_mimes", 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..3575a5a65 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_headers::{SURROGATE_KEY_WARNINGS, 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,72 @@ 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")?; + } + } + + if let Some(cdn) = ctx.cdn() { + cdn.purge_surrogate_keys(iter::once(SURROGATE_KEY_WARNINGS)) + .await + .context("failed to purge CDN for warnings")?; + } + + 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/Cargo.toml b/crates/bin/docs_rs_web/Cargo.toml index 0b8f5025e..4d2139a9f 100644 --- a/crates/bin/docs_rs_web/Cargo.toml +++ b/crates/bin/docs_rs_web/Cargo.toml @@ -33,6 +33,7 @@ docs_rs_config = { path = "../../lib/docs_rs_config" } docs_rs_context = { path = "../../lib/docs_rs_context" } docs_rs_database = { path = "../../lib/docs_rs_database" } docs_rs_env_vars = { path = "../../lib/docs_rs_env_vars" } +docs_rs_fastly = { path = "../../lib/docs_rs_fastly" } docs_rs_headers = { path = "../../lib/docs_rs_headers" } docs_rs_logging = { path = "../../lib/docs_rs_logging" } docs_rs_mimes = { path = "../../lib/docs_rs_mimes" } diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 10584abb8..928d2de60 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -107,6 +107,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; } diff --git a/crates/bin/docs_rs_web/src/handlers/build_status.rs b/crates/bin/docs_rs_web/src/handlers/build_status.rs new file mode 100644 index 000000000..ac560a4c2 --- /dev/null +++ b/crates/bin/docs_rs_web/src/handlers/build_status.rs @@ -0,0 +1,214 @@ +use crate::{ + cache::CachePolicy, + error::{AxumNope, AxumResult}, + extractors::{DbConnection, rustdoc::RustdocParams}, + match_release::match_version, +}; +use axum::{ + Json, extract::Extension, http::header::ACCESS_CONTROL_ALLOW_ORIGIN, response::IntoResponse, +}; + +pub(crate) async fn status_handler( + params: RustdocParams, + mut conn: DbConnection, +) -> impl IntoResponse { + ( + Extension(CachePolicy::NoStoreMustRevalidate), + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], + // We use an async block to emulate a try block so that we can apply the above CORS header + // and cache policy to both successful and failed responses + async move { + let matched_release = match_version(&mut conn, params.name(), params.req_version()) + .await? + .assume_exact_name()?; + + let rustdoc_status = matched_release.rustdoc_status(); + + let version = matched_release + .into_canonical_req_version_or_else(|confirmed_name, version| { + AxumNope::Redirect( + params + .clone() + .with_name(confirmed_name) + .with_req_version(version) + .build_status_url(), + CachePolicy::NoCaching, + ) + })? + .into_version(); + + let json = Json(serde_json::json!({ + "version": version.to_string(), + "doc_status": rustdoc_status, + })); + + AxumResult::Ok(json.into_response()) + } + .await, + ) +} + +#[cfg(test)] +mod tests { + use crate::{ + cache::CachePolicy, + testing::{AxumResponseTestExt, AxumRouterTestExt, TestEnvironmentExt as _, async_wrapper}, + }; + use docs_rs_types::ReqVersion; + use reqwest::StatusCode; + use test_case::test_case; + + #[test_case("latest")] + #[test_case("0.1")] + #[test_case("0.1.0")] + #[test_case("=0.1.0"; "exact_version")] + fn status(req_version: &str) { + async_wrapper(|env| async move { + let req_version: ReqVersion = req_version.parse()?; + + env.fake_release() + .await + .name("foo") + .version("0.1.0") + .create() + .await?; + + let response = env + .web_app() + .await + .get_and_follow_redirects(&format!("/crate/foo/{req_version}/status.json")) + .await?; + response.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = serde_json::from_str(&response.text().await?)?; + + assert_eq!( + value, + serde_json::json!({ + "version": "0.1.0", + "doc_status": true, + }) + ); + + Ok(()) + }); + } + + #[test] + fn redirect_latest() { + async_wrapper(|env| async move { + env.fake_release() + .await + .name("foo") + .version("0.1.0") + .create() + .await?; + + let web = env.web_app().await; + let redirect = web + .assert_redirect("/crate/foo/*/status.json", "/crate/foo/latest/status.json") + .await?; + redirect.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); + assert_eq!(redirect.headers()["access-control-allow-origin"], "*"); + + Ok(()) + }); + } + + #[test_case("0.1")] + #[test_case("~0.1"; "semver")] + fn redirect(req_version: &str) { + async_wrapper(|env| async move { + let req_version: ReqVersion = req_version.parse()?; + + env.fake_release() + .await + .name("foo") + .version("0.1.0") + .create() + .await?; + + let web = env.web_app().await; + let redirect = web + .assert_redirect( + &format!("/crate/foo/{req_version}/status.json"), + "/crate/foo/0.1.0/status.json", + ) + .await?; + redirect.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); + assert_eq!(redirect.headers()["access-control-allow-origin"], "*"); + + Ok(()) + }); + } + + #[test_case("latest")] + #[test_case("0.1")] + #[test_case("0.1.0")] + #[test_case("=0.1.0"; "exact_version")] + fn failure(req_version: &str) { + async_wrapper(|env| async move { + let req_version: ReqVersion = req_version.parse()?; + + env.fake_release() + .await + .name("foo") + .version("0.1.0") + .build_result_failed() + .create() + .await?; + + let response = env + .web_app() + .await + .get_and_follow_redirects(&format!("/crate/foo/{req_version}/status.json")) + .await?; + response.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = serde_json::from_str(&response.text().await?)?; + + assert_eq!( + value, + serde_json::json!({ + "version": "0.1.0", + "doc_status": false, + }) + ); + + Ok(()) + }); + } + + // crate not found + #[test_case("bar", "0.1")] + #[test_case("bar", "0.1.0")] + // version not found + #[test_case("foo", "=0.1.0"; "exact_version")] + #[test_case("foo", "0.2")] + #[test_case("foo", "0.2.0")] + // invalid semver + #[test_case("foo", "0,1")] + #[test_case("foo", "0,1,0")] + fn not_found(krate: &str, req_version: &str) { + async_wrapper(|env| async move { + env.fake_release() + .await + .name("foo") + .version("0.1.1") + .create() + .await?; + + let response = env + .web_app() + .await + .get_and_follow_redirects(&format!("/crate/{krate}/{req_version}/status.json")) + .await?; + response.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + Ok(()) + }); + } +} diff --git a/crates/bin/docs_rs_web/src/handlers/mod.rs b/crates/bin/docs_rs_web/src/handlers/mod.rs index c1ccd760e..3057c677f 100644 --- a/crates/bin/docs_rs_web/src/handlers/mod.rs +++ b/crates/bin/docs_rs_web/src/handlers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod about; pub(crate) mod build_details; +pub(crate) mod build_status; pub(crate) mod builds; pub(crate) mod crate_details; pub(crate) mod features; @@ -15,7 +16,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 +76,14 @@ async fn apply_middleware( template_data: Option>, ) -> Result { let has_templates = template_data.is_some(); + let warnings_cache = Arc::new( + WarningsCache::new( + context.pool()?.clone(), + context.build_queue()?.clone(), + context.cdn().cloned(), + ) + .await, + ); let web_metrics = Arc::new(WebMetrics::new(&context.meter_provider)); @@ -103,6 +112,7 @@ async fn apply_middleware( .layer(Extension(config.clone())) .layer(Extension(context.registry_api()?.clone())) .layer(Extension(context.storage()?.clone())) + .layer(Extension(warnings_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( @@ -262,6 +272,26 @@ mod tests { }); } + #[tokio::test(flavor = "multi_thread")] + async fn test_abnormalities_placeholder_is_rendered() -> Result<()> { + let env = TestEnvironment::new().await?; + + let web = env.web_app().await; + let page = kuchikiki::parse_html().one(web.assert_success("/").await?.text().await?); + let placeholder = page + .select("#abnormalities") + .unwrap() + .next() + .expect("missing abnormalities placeholder"); + + assert_eq!( + placeholder.attributes.borrow().get("data-url"), + Some("/-/partial/abnormalities/") + ); + assert_eq!(page.select("a.pure-menu-link.error").unwrap().count(), 0); + 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 ca19a053c..ce2cf0ef0 100644 --- a/crates/bin/docs_rs_web/src/handlers/releases.rs +++ b/crates/bin/docs_rs_web/src/handlers/releases.rs @@ -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] @@ -1820,6 +1825,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?; @@ -1855,6 +1861,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 99ee16e74..31e8afe61 100644 --- a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs +++ b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs @@ -18,8 +18,7 @@ use crate::{ TemplateData, templates::{RenderBrands, RenderRegular, RenderSolid, filters}, }, - utils, - utils::licenses, + utils::{self, licenses}, }; use anyhow::{Context as _, anyhow}; use askama::Template; diff --git a/crates/bin/docs_rs_web/src/handlers/status.rs b/crates/bin/docs_rs_web/src/handlers/status.rs index ac560a4c2..95c34a38d 100644 --- a/crates/bin/docs_rs_web/src/handlers/status.rs +++ b/crates/bin/docs_rs_web/src/handlers/status.rs @@ -1,214 +1,322 @@ use crate::{ cache::CachePolicy, - error::{AxumNope, AxumResult}, - extractors::{DbConnection, rustdoc::RustdocParams}, - match_release::match_version, + error::AxumResult, + impl_axum_webpage, + page::{ + templates::{AlertSeverityRender, RenderBrands, RenderSolid}, + warnings::{ActiveAbnormalities, WarningsCache}, + }, }; +use askama::Template; use axum::{ - Json, extract::Extension, http::header::ACCESS_CONTROL_ALLOW_ORIGIN, response::IntoResponse, + extract::Extension, + response::{IntoResponse, Response as AxumResponse}, }; +use docs_rs_database::service_config::Abnormality; +use docs_rs_headers::SURROGATE_KEY_WARNINGS; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Template)] +#[template(path = "core/about/status.html")] +struct AboutStatus { + abnormalities: Vec, +} + +impl_axum_webpage!( + AboutStatus, + cache_policy = |_| CachePolicy::ForeverInCdn(SURROGATE_KEY_WARNINGS.into()), +); + +#[derive(Template)] +#[template(path = "header/abnormalities.html")] +#[derive(Debug, Clone)] +struct Abnormalities { + abnormalities: ActiveAbnormalities, +} + +impl_axum_webpage! { + Abnormalities, + cache_policy = |_| CachePolicy::ForeverInCdn( + SURROGATE_KEY_WARNINGS.into() + ), +} pub(crate) async fn status_handler( - params: RustdocParams, - mut conn: DbConnection, -) -> impl IntoResponse { - ( - Extension(CachePolicy::NoStoreMustRevalidate), - [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], - // We use an async block to emulate a try block so that we can apply the above CORS header - // and cache policy to both successful and failed responses - async move { - let matched_release = match_version(&mut conn, params.name(), params.req_version()) - .await? - .assume_exact_name()?; - - let rustdoc_status = matched_release.rustdoc_status(); - - let version = matched_release - .into_canonical_req_version_or_else(|confirmed_name, version| { - AxumNope::Redirect( - params - .clone() - .with_name(confirmed_name) - .with_req_version(version) - .build_status_url(), - CachePolicy::NoCaching, - ) - })? - .into_version(); - - let json = Json(serde_json::json!({ - "version": version.to_string(), - "doc_status": rustdoc_status, - })); - - AxumResult::Ok(json.into_response()) - } - .await, - ) + Extension(warnings_cache): Extension>, +) -> AxumResult { + Ok(AboutStatus { + abnormalities: warnings_cache.get().await.abnormalities, + }) +} + +pub(crate) async fn abnormalities( + Extension(warnings): Extension>, +) -> AxumResult { + Ok(Abnormalities { + abnormalities: warnings.get().await.abnormalities, + } + .into_response()) } #[cfg(test)] mod tests { - use crate::{ - cache::CachePolicy, - testing::{AxumResponseTestExt, AxumRouterTestExt, TestEnvironmentExt as _, async_wrapper}, + use crate::testing::{ + AxumResponseTestExt, AxumRouterTestExt, TestEnvironment, TestEnvironmentExt as _, + }; + use anyhow::Result; + use chrono::{TimeZone as _, Utc}; + use docs_rs_config::AppConfig as _; + use docs_rs_database::service_config::{ + Abnormality, AlertSeverity, AnchorId, ConfigName, set_config, }; - use docs_rs_types::ReqVersion; - use reqwest::StatusCode; - use test_case::test_case; - - #[test_case("latest")] - #[test_case("0.1")] - #[test_case("0.1.0")] - #[test_case("=0.1.0"; "exact_version")] - fn status(req_version: &str) { - async_wrapper(|env| async move { - let req_version: ReqVersion = req_version.parse()?; - - env.fake_release() - .await - .name("foo") - .version("0.1.0") - .create() - .await?; - - let response = env - .web_app() - .await - .get_and_follow_redirects(&format!("/crate/foo/{req_version}/status.json")) - .await?; - response.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); - assert_eq!(response.headers()["access-control-allow-origin"], "*"); - assert_eq!(response.status(), StatusCode::OK); - let value: serde_json::Value = serde_json::from_str(&response.text().await?)?; - - assert_eq!( - value, - serde_json::json!({ - "version": "0.1.0", - "doc_status": true, - }) - ); - - Ok(()) - }); + use docs_rs_types::{KrateName, testing::V1}; + use docs_rs_uri::EscapedURI; + use kuchikiki::traits::TendrilSink; + use std::str::FromStr; + + #[tokio::test(flavor = "multi_thread")] + async fn abnormalities_partial_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("/-/partial/abnormalities/") + .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(()) } - #[test] - fn redirect_latest() { - async_wrapper(|env| async move { - env.fake_release() - .await - .name("foo") - .version("0.1.0") - .create() - .await?; - - let web = env.web_app().await; - let redirect = web - .assert_redirect("/crate/foo/*/status.json", "/crate/foo/latest/status.json") - .await?; - redirect.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); - assert_eq!(redirect.headers()["access-control-allow-origin"], "*"); - - Ok(()) - }); + #[tokio::test(flavor = "multi_thread")] + async fn abnormalities_partial_renders_queue_alert() -> 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("/-/partial/abnormalities/") + .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(()) } - #[test_case("0.1")] - #[test_case("~0.1"; "semver")] - fn redirect(req_version: &str) { - async_wrapper(|env| async move { - let req_version: ReqVersion = req_version.parse()?; - - env.fake_release() - .await - .name("foo") - .version("0.1.0") - .create() - .await?; - - let web = env.web_app().await; - let redirect = web - .assert_redirect( - &format!("/crate/foo/{req_version}/status.json"), - "/crate/foo/0.1.0/status.json", + #[tokio::test(flavor = "multi_thread")] + async fn 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("/-/partial/abnormalities/") + .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(), ) - .await?; - redirect.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); - assert_eq!(redirect.headers()["access-control-allow-origin"], "*"); + }) + .collect::>(); - Ok(()) - }); + 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_case("latest")] - #[test_case("0.1")] - #[test_case("0.1.0")] - #[test_case("=0.1.0"; "exact_version")] - fn failure(req_version: &str) { - async_wrapper(|env| async move { - let req_version: ReqVersion = req_version.parse()?; - - env.fake_release() - .await - .name("foo") - .version("0.1.0") - .build_result_failed() - .create() - .await?; - - let response = env - .web_app() - .await - .get_and_follow_redirects(&format!("/crate/foo/{req_version}/status.json")) - .await?; - response.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); - assert_eq!(response.headers()["access-control-allow-origin"], "*"); - assert_eq!(response.status(), StatusCode::OK); - let value: serde_json::Value = serde_json::from_str(&response.text().await?)?; - - assert_eq!( - value, - serde_json::json!({ - "version": "0.1.0", - "doc_status": false, - }) - ); - - 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(()) } - // crate not found - #[test_case("bar", "0.1")] - #[test_case("bar", "0.1.0")] - // version not found - #[test_case("foo", "=0.1.0"; "exact_version")] - #[test_case("foo", "0.2")] - #[test_case("foo", "0.2.0")] - // invalid semver - #[test_case("foo", "0,1")] - #[test_case("foo", "0,1,0")] - fn not_found(krate: &str, req_version: &str) { - async_wrapper(|env| async move { - env.fake_release() - .await - .name("foo") - .version("0.1.1") - .create() - .await?; - - let response = env - .web_app() - .await - .get_and_follow_redirects(&format!("/crate/{krate}/{req_version}/status.json")) - .await?; - response.assert_cache_control(CachePolicy::NoStoreMustRevalidate, env.config()); - assert_eq!(response.headers()["access-control-allow-origin"], "*"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - 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/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..8dc52655a 100644 --- a/crates/bin/docs_rs_web/src/page/mod.rs +++ b/crates/bin/docs_rs_web/src/page/mod.rs @@ -1,12 +1,5 @@ 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, -} 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..af5876c1f --- /dev/null +++ b/crates/bin/docs_rs_web/src/page/warnings.rs @@ -0,0 +1,456 @@ +use anyhow::{Context as _, Result}; +use chrono::Utc; +use docs_rs_build_queue::AsyncBuildQueue; +use docs_rs_database::{ + Pool, + service_config::{Abnormality, AnchorId, ConfigName, get_config}, +}; +use docs_rs_fastly::{Cdn, CdnBehaviour as _}; +use docs_rs_headers::SURROGATE_KEY_WARNINGS; +use serde::Serialize; +use std::{iter, 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, + cdn: Option>, + ) -> Self { + Self::new_with_ttl(pool, build_queue, cdn, Self::TTL).await + } + + async fn new_with_ttl( + pool: Pool, + build_queue: Arc, + cdn: Option>, + 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(); + Self::purge_cdn(cdn.as_deref()).await; + + 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 automated_abnormalities_changed = !Self::non_manual_abnormalities_equal( + &previous_abnormalities, + &abnormalities, + ); + + let mut state = refresh_state.write().await; + state.abnormalities = abnormalities; + drop(state); + + if automated_abnormalities_changed { + Self::purge_cdn(cdn.as_deref()).await; + } + } + } + }); + + 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 non_manual_abnormalities_equal(previous: &[Abnormality], current: &[Abnormality]) -> bool { + fn non_manual_abnormalities( + abnormalities: &[Abnormality], + ) -> impl Iterator { + abnormalities + .iter() + .filter(|abnormality| abnormality.anchor_id != AnchorId::Manual) + } + + non_manual_abnormalities(previous).eq(non_manual_abnormalities(current)) + } + + async fn purge_cdn(cdn: Option<&Cdn>) { + let Some(cdn) = cdn else { + return; + }; + + if let Err(err) = cdn + .purge_surrogate_keys(iter::once(SURROGATE_KEY_WARNINGS)) + .await + { + error!(?err, "failed to purge warnings CDN cache"); + } + } + + 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(), + None, + 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(), + None, + 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(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn cache_purges_cdn_only_when_automated_abnormalities_change() -> 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 cache = WarningsCache::new_with_ttl( + env.pool()?.clone(), + env.build_queue()?.clone(), + Some(env.cdn().clone()), + Duration::from_millis(25), + ) + .await; + + assert_eq!( + env.cdn().purged_keys().await?.to_string(), + SURROGATE_KEY_WARNINGS.to_str().unwrap() + ); + + 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); + + sleep(Duration::from_millis(75)).await; + assert_eq!( + env.cdn().purged_keys().await?.to_string(), + SURROGATE_KEY_WARNINGS.to_str().unwrap() + ); + + 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?; + } + + sleep(Duration::from_millis(75)).await; + + assert_eq!( + env.cdn().purged_keys().await?.to_string(), + SURROGATE_KEY_WARNINGS.to_str().unwrap() + ); + + drop(cache); + + Ok(()) + } +} diff --git a/crates/bin/docs_rs_web/src/routes.rs b/crates/bin/docs_rs_web/src/routes.rs index 8fef55986..bd5f67144 100644 --- a/crates/bin/docs_rs_web/src/routes.rs +++ b/crates/bin/docs_rs_web/src/routes.rs @@ -2,7 +2,8 @@ use crate::{ cache::CachePolicy, error::AxumNope, handlers::{ - about, build_details, builds, crate_details, features, releases, rustdoc, sitemap, source, + about, build_details, build_status, builds, crate_details, features, releases, rustdoc, + sitemap, source, statics::{build_static_router, static_root_dir}, status, }, @@ -143,6 +144,7 @@ pub(crate) fn build_axum_routes() -> Result { "/-/sitemap/{letter}/sitemap.xml", get_internal(sitemap::sitemap_handler), ) + .route_with_tsr("/-/status/", get_internal(status::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)) @@ -216,7 +218,7 @@ pub(crate) fn build_axum_routes() -> Result { ) .route( "/crate/{name}/{version}/status.json", - get_internal(status::status_handler), + get_internal(build_status::status_handler), ) .route_with_tsr( "/crate/{name}/{version}/builds/{id}", @@ -254,6 +256,10 @@ pub(crate) fn build_axum_routes() -> Result { "/crate/{name}/{version}/menus/releases/{*path}", get_internal(crate_details::get_all_releases), ) + .route( + "/-/partial/abnormalities/", + get_internal(status::abnormalities), + ) .route( "/-/rustdoc.static/{*path}", get_internal(rustdoc::static_asset_handler), diff --git a/crates/bin/docs_rs_web/static/menu.js b/crates/bin/docs_rs_web/static/menu.js index 4d63de4ee..ddc858c6a 100644 --- a/crates/bin/docs_rs_web/static/menu.js +++ b/crates/bin/docs_rs_web/static/menu.js @@ -312,4 +312,19 @@ history.replaceState({}, null, permalink.href); } }); + + (async function loadAbnormalities() { + const abnormalities = document.getElementById("abnormalities"); + if (!abnormalities) { + return; + } + + try { + const response = await fetch(abnormalities.dataset.url); + abnormalities.innerHTML = await response.text(); + } catch (ex) { + console.error(`Failed to load abnormalities: ${ex}`); + abnormalities.innerHTML = ""; + } + })(); })(); 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..00e44dd25 --- /dev/null +++ b/crates/bin/docs_rs_web/templates/header/abnormalities.html @@ -0,0 +1,25 @@ +{%- 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..3c73a17ee 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 #} +
      {# 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_headers/src/lib.rs b/crates/lib/docs_rs_headers/src/lib.rs index faee9f21b..b2fd77f6e 100644 --- a/crates/lib/docs_rs_headers/src/lib.rs +++ b/crates/lib/docs_rs_headers/src/lib.rs @@ -19,3 +19,7 @@ pub static SURROGATE_CONTROL: HeaderName = HeaderName::from_static("surrogate-co /// X-Robots-Tag header for search engines. pub static X_ROBOTS_TAG: HeaderName = HeaderName::from_static("x-robots-tag"); + +/// A surrogate key that we apply to warnings & abnormalities. +/// Invalidated by the CLI commands & the alert evalutation. +pub const SURROGATE_KEY_WARNINGS: SurrogateKey = SurrogateKey::from_static("docs-rs-warnings"); 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" +}