diff --git a/Cargo.lock b/Cargo.lock index d839ac44779..66c9036aad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,16 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tokio-tar" version = "0.5.2" @@ -1350,6 +1360,7 @@ dependencies = [ "crates_io_database", "crates_io_database_dump", "crates_io_diesel_helpers", + "crates_io_docs_rs", "crates_io_env_vars", "crates_io_github", "crates_io_index", @@ -1505,6 +1516,28 @@ dependencies = [ "serde", ] +[[package]] +name = "crates_io_docs_rs" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "claims", + "crates_io_env_vars", + "http 1.3.1", + "mockall", + "mockito", + "reqwest", + "serde", + "serde_json", + "test-case", + "thiserror 2.0.12", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "crates_io_env_vars" version = "0.0.0" @@ -3686,6 +3719,30 @@ dependencies = [ "syn", ] +[[package]] +name = "mockito" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "log", + "rand 0.9.1", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "moka" version = "0.12.10" @@ -5501,6 +5558,39 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index fdefc765361..f3cadb81920 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ crates_io_cdn_logs = { path = "crates/crates_io_cdn_logs" } crates_io_database = { path = "crates/crates_io_database" } crates_io_database_dump = { path = "crates/crates_io_database_dump" } crates_io_diesel_helpers = { path = "crates/crates_io_diesel_helpers" } +crates_io_docs_rs = { path = "crates/crates_io_docs_rs" } crates_io_env_vars = { path = "crates/crates_io_env_vars" } crates_io_github = { path = "crates/crates_io_github" } crates_io_index = { path = "crates/crates_io_index" } @@ -138,6 +139,7 @@ utoipa-axum = "=0.2.0" [dev-dependencies] bytes = "=1.10.1" +crates_io_docs_rs = { path = "crates/crates_io_docs_rs", features = ["mock"] } crates_io_github = { path = "crates/crates_io_github", features = ["mock"] } crates_io_index = { path = "crates/crates_io_index", features = ["testing"] } crates_io_tarball = { path = "crates/crates_io_tarball", features = ["builder"] } diff --git a/crates/crates_io_docs_rs/Cargo.toml b/crates/crates_io_docs_rs/Cargo.toml new file mode 100644 index 00000000000..bc03ab59b57 --- /dev/null +++ b/crates/crates_io_docs_rs/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "crates_io_docs_rs" +version = "0.0.0" +license = "MIT OR Apache-2.0" +edition = "2024" + +[lints] +workspace = true + +[features] +mock = ["dep:mockall"] + +[dependencies] +anyhow = "=1.0.98" +async-trait = "=0.1.88" +crates_io_env_vars = { path = "../crates_io_env_vars" } +http = "=1.3.1" +mockall = { version = "=0.13.1", optional = true } +reqwest = { version = "=0.12.15", features = ["json"] } +serde = { version = "=1.0.219", features = ["derive"] } +thiserror = "=2.0.12" +tracing = "=0.1.41" +url = "=2.5.4" + +[dev-dependencies] +claims = "=0.8.0" +serde_json = "=1.0.140" +mockito = "=1.7.0" +test-case = "=3.3.1" +tokio = { version = "=1.45.0", features = ["macros", "rt-multi-thread"] } +tracing-subscriber = "=0.3.19" diff --git a/crates/crates_io_docs_rs/README.md b/crates/crates_io_docs_rs/README.md new file mode 100644 index 00000000000..ffc46f67272 --- /dev/null +++ b/crates/crates_io_docs_rs/README.md @@ -0,0 +1,12 @@ +# crates_io_docs_rs + +This package implements functionality for interacting with the docs.rs API. + +It contains a `DocsRsClient` trait that defines the supported operations, that +the crates.io codebase needs to interact with docs.rs. The `RealDocsRsClient` +struct is an implementation of this trait that uses the `reqwest` crate to +perform the actual HTTP requests. + +If the `mock` feature is enabled, a `MockDocsRsClient` struct is available, +which can be used for testing purposes. This struct is generated automatically +by the [`mockall`](https://docs.rs/mockall) crate. diff --git a/crates/crates_io_docs_rs/examples/test_docs_rs_client.rs b/crates/crates_io_docs_rs/examples/test_docs_rs_client.rs new file mode 100644 index 00000000000..e4ccbb8f83b --- /dev/null +++ b/crates/crates_io_docs_rs/examples/test_docs_rs_client.rs @@ -0,0 +1,18 @@ +use anyhow::{Result, anyhow}; +use crates_io_docs_rs::{DocsRsClient, RealDocsRsClient}; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let access_token = env::args() + .nth(1) + .ok_or_else(|| anyhow!("Missing access token"))?; + + let docs_rs = RealDocsRsClient::new("https://docs.rs", access_token)?; + + docs_rs.rebuild_docs("empty-library", "1.0.0").await?; + + Ok(()) +} diff --git a/crates/crates_io_docs_rs/src/lib.rs b/crates/crates_io_docs_rs/src/lib.rs new file mode 100644 index 00000000000..7e08046ac7f --- /dev/null +++ b/crates/crates_io_docs_rs/src/lib.rs @@ -0,0 +1,231 @@ +#![doc = include_str!("../README.md")] + +use async_trait::async_trait; +use crates_io_env_vars::{var, var_parsed}; +use http::StatusCode; +use reqwest::IntoUrl; +use serde::Deserialize; +use tracing::warn; +use url::Url; + +#[derive(Debug, thiserror::Error)] +pub enum DocsRsError { + /// The rebuild couldn't be triggered. + /// The reason is passed in the given error message. + #[error("Bad request: {0}")] + BadRequest(String), + /// The request was rate limited by the server. + /// This is the NGINX level rate limit for requests coming from a single IP. + /// This is _not_ the rate limit that docs.rs might apply for rebuilds of the same crate + /// (AKA: "rebuild too often"). + #[error("rate limited")] + RateLimited, + #[error("unauthorized")] + Unauthorized, + /// crate or version not found on docs.rs. + /// This can be temporary directly after a release until the first + /// docs build was started for the crate. + #[error("crate or version not found on docs.rs")] + NotFound, + #[error(transparent)] + Other(anyhow::Error), +} + +#[cfg_attr(feature = "mock", mockall::automock)] +#[async_trait] +pub trait DocsRsClient: Send + Sync { + async fn rebuild_docs(&self, name: &str, version: &str) -> Result<(), DocsRsError>; +} + +pub struct RealDocsRsClient { + client: reqwest::Client, + base_url: Url, + api_token: String, +} + +impl RealDocsRsClient { + pub fn new(base_url: impl IntoUrl, api_token: impl Into) -> Result { + Ok(Self { + client: reqwest::Client::builder() + .user_agent("crates.io") + .build() + .unwrap(), + base_url: base_url + .into_url() + .map_err(|err| DocsRsError::Other(err.into()))?, + api_token: api_token.into(), + }) + } + + pub fn from_environment() -> Option { + let base_url: Url = match var_parsed("DOCS_RS_BASE_URL") { + Ok(Some(url)) => url, + Ok(None) => Url::parse("https://docs.rs").unwrap(), + Err(err) => { + warn!(?err, "Failed to parse DOCS_RS_BASE_URL"); + return None; + } + }; + + let api_token = var("DOCS_RS_API_TOKEN").ok()??; + + Some(Self::new(base_url, api_token).expect("URL is always valid here")) + } +} + +#[async_trait] +impl DocsRsClient for RealDocsRsClient { + async fn rebuild_docs(&self, name: &str, version: &str) -> Result<(), DocsRsError> { + let target_url = self + .base_url + .join(&format!("/crate/{name}/{version}/rebuild")) + .map_err(|err| DocsRsError::Other(err.into()))?; + + let response = self + .client + .post(target_url) + .bearer_auth(&self.api_token) + .send() + .await + .map_err(|err| DocsRsError::Other(err.into()))?; + + match response.status() { + StatusCode::CREATED => Ok(()), + StatusCode::NOT_FOUND => Err(DocsRsError::NotFound), + StatusCode::TOO_MANY_REQUESTS => Err(DocsRsError::RateLimited), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(DocsRsError::Unauthorized), + StatusCode::BAD_REQUEST => { + #[derive(Deserialize)] + struct BadRequestResponse { + message: String, + } + + let error_response: BadRequestResponse = response + .json() + .await + .map_err(|err| DocsRsError::Other(err.into()))?; + + Err(DocsRsError::BadRequest(error_response.message)) + } + _ => Err(DocsRsError::Other(anyhow::anyhow!( + "Unexpected response from docs.rs: {}\n{}", + response.status(), + response.text().await.unwrap_or_default() + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::assert_matches; + use test_case::test_case; + + async fn mock( + krate: &str, + version: &str, + status: StatusCode, + ) -> (mockito::ServerGuard, mockito::Mock) { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", &format!("/crate/{krate}/{version}/rebuild")[..]) + .match_header("Authorization", "Bearer test_token") + .with_status(StatusCode::CREATED.as_u16().into()) + .with_status(status.as_u16().into()); + + (server, mock) + } + + #[tokio::test] + async fn test_ok() -> anyhow::Result<()> { + let (server, mock) = mock("krate", "0.1.0", StatusCode::CREATED).await; + mock.create(); + + let docs_rs = RealDocsRsClient::new(server.url(), "test_token")?; + + docs_rs.rebuild_docs("krate", "0.1.0").await?; + + Ok(()) + } + + #[tokio::test] + async fn test_crate_not_found() -> anyhow::Result<()> { + let (server, mock) = mock("krate", "0.1.0", StatusCode::NOT_FOUND).await; + mock.create(); + + let docs_rs = RealDocsRsClient::new(server.url(), "test_token")?; + + assert_matches!( + docs_rs.rebuild_docs("krate", "0.1.0").await, + Err(DocsRsError::NotFound) + ); + + Ok(()) + } + + #[tokio::test] + async fn test_crate_too_many_requests() -> anyhow::Result<()> { + let (server, mock) = mock("krate", "0.1.0", StatusCode::TOO_MANY_REQUESTS).await; + mock.create(); + + let docs_rs = RealDocsRsClient::new(server.url(), "test_token")?; + + assert_matches!( + docs_rs.rebuild_docs("krate", "0.1.0").await, + Err(DocsRsError::RateLimited) + ); + + Ok(()) + } + + #[tokio::test] + #[test_case(StatusCode::UNAUTHORIZED)] + #[test_case(StatusCode::FORBIDDEN)] + async fn test_permissions(status: StatusCode) -> anyhow::Result<()> { + let (server, mock) = mock("krate", "0.1.0", status).await; + mock.create(); + + let docs_rs = RealDocsRsClient::new(server.url(), "test_token")?; + + assert_matches!( + docs_rs.rebuild_docs("krate", "0.1.0").await, + Err(DocsRsError::Unauthorized) + ); + + Ok(()) + } + + #[tokio::test] + async fn test_bad_request_message() -> anyhow::Result<()> { + let (server, mock) = mock("krate", "0.1.0", StatusCode::BAD_REQUEST).await; + let body = serde_json::to_vec(&serde_json::json!({ + "message": "some error message" + }))?; + mock.with_body(&body).create(); + + let docs_rs = RealDocsRsClient::new(server.url(), "test_token")?; + + assert_matches!( + docs_rs.rebuild_docs("krate", "0.1.0").await, + Err(DocsRsError::BadRequest(msg)) if msg == "some error message" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_server_error() -> anyhow::Result<()> { + let (server, mock) = mock("krate", "0.1.0", StatusCode::INTERNAL_SERVER_ERROR).await; + mock.create(); + + let docs_rs = RealDocsRsClient::new(server.url(), "test_token")?; + + assert_matches!( + docs_rs.rebuild_docs("krate", "0.1.0").await, + Err(DocsRsError::Other(_)) + ); + + Ok(()) + } +} diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs index 12c2e13a37b..22ff1dd91cf 100644 --- a/src/bin/background-worker.rs +++ b/src/bin/background-worker.rs @@ -21,6 +21,7 @@ use crates_io::ssh; use crates_io::storage::Storage; use crates_io::worker::{Environment, RunnerExt}; use crates_io::{Emails, config}; +use crates_io_docs_rs::{DocsRsClient, RealDocsRsClient}; use crates_io_env_vars::var; use crates_io_index::RepositoryConfig; use crates_io_team_repo::TeamRepoImpl; @@ -82,6 +83,9 @@ fn main() -> anyhow::Result<()> { let fastly = Fastly::from_environment(client.clone()); let team_repo = TeamRepoImpl::default(); + let docs_rs: Option> = + RealDocsRsClient::from_environment().map(|cl| Box::new(cl) as _); + let deadpool = create_database_pool(&config.db.primary); let environment = Environment::builder() @@ -93,6 +97,7 @@ fn main() -> anyhow::Result<()> { .downloads_archive_store(downloads_archive_store) .deadpool(deadpool.clone()) .emails(emails) + .maybe_docs_rs(docs_rs) .team_repo(Box::new(team_repo)) .build(); diff --git a/src/controllers/version.rs b/src/controllers/version.rs index 8c8885e6cbb..573b8678aef 100644 --- a/src/controllers/version.rs +++ b/src/controllers/version.rs @@ -1,5 +1,6 @@ pub mod authors; pub mod dependencies; +pub mod docs; pub mod downloads; pub mod metadata; pub mod readme; diff --git a/src/controllers/version/docs.rs b/src/controllers/version/docs.rs new file mode 100644 index 00000000000..fc0e68477e9 --- /dev/null +++ b/src/controllers/version/docs.rs @@ -0,0 +1,155 @@ +//! Endpoint for triggering a docs.rs rebuild + +use super::CrateVersionPath; +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::helpers::authorization::Rights; +use crate::util::errors::{AppResult, custom, server_error}; +use crate::worker::jobs; +use axum::response::{IntoResponse as _, Response}; +use crates_io_worker::BackgroundJob as _; +use http::StatusCode; +use http::request::Parts; + +/// Trigger a rebuild for the crate documentation on docs.rs. +#[utoipa::path( + post, + path = "/api/v1/crates/{name}/{version}/rebuild_docs", + params(CrateVersionPath), + security( + ("cookie" = []), + ), + tag = "versions", + responses((status = 201, description = "Successful Response")), +)] +pub async fn rebuild_version_docs( + app: AppState, + path: CrateVersionPath, + req: Parts, +) -> AppResult { + let mut conn = app.db_write().await?; + let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + + // validate if version & crate exist + let (_, krate) = path.load_version_and_crate(&mut conn).await?; + + // Check that the user is an owner of the crate, or a team member (= publish rights) + let user = auth.user(); + let owners = krate.owners(&mut conn).await?; + if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { + return Err(custom( + StatusCode::FORBIDDEN, + "user doesn't have permission to trigger a docs rebuild", + )); + } + + jobs::DocsRsQueueRebuild::new(path.name, path.version) + .enqueue(&mut conn) + .await + .map_err(|error| { + error!( + ?error, + "docs_rs_queue_rebuild: Failed to enqueue background job" + ); + server_error("failed to enqueue background job") + })?; + + Ok(StatusCode::CREATED.into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::{ + builders::{CrateBuilder, VersionBuilder}, + util::{RequestHelper as _, TestApp}, + }; + use crates_io_database::models::NewUser; + use crates_io_docs_rs::MockDocsRsClient; + + #[tokio::test(flavor = "multi_thread")] + async fn test_trigger_rebuild_ok() -> anyhow::Result<()> { + let mut docs_rs_mock = MockDocsRsClient::new(); + docs_rs_mock + .expect_rebuild_docs() + .returning(|_, _| Ok(())) + .times(1); + + let (app, _client, cookie_client) = + TestApp::full().with_docs_rs(docs_rs_mock).with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new("krate", cookie_client.as_model().id) + .version(VersionBuilder::new("0.1.0")) + .build(&mut conn) + .await?; + + let response = cookie_client + .post::<()>("/api/v1/crates/krate/0.1.0/rebuild_docs", "") + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + app.run_pending_background_jobs().await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_trigger_rebuild_permission_failed() -> anyhow::Result<()> { + let mut docs_rs_mock = MockDocsRsClient::new(); + docs_rs_mock + .expect_rebuild_docs() + .returning(|_, _| Ok(())) + .never(); + + let (app, _client, cookie_client) = + TestApp::full().with_docs_rs(docs_rs_mock).with_user().await; + + let mut conn = app.db_conn().await; + + let other_user = NewUser::builder() + .gh_id(111) + .gh_login("other_user") + .gh_access_token("token") + .build() + .insert(&mut conn) + .await?; + + CrateBuilder::new("krate", other_user.id) + .version(VersionBuilder::new("0.1.0")) + .build(&mut conn) + .await?; + + let response = cookie_client + .post::<()>("/api/v1/crates/krate/0.1.0/rebuild_docs", "") + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + app.run_pending_background_jobs().await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_trigger_rebuild_unknown_crate_doesnt_queue_job() -> anyhow::Result<()> { + let mut docs_rs_mock = MockDocsRsClient::new(); + docs_rs_mock + .expect_rebuild_docs() + .returning(|_, _| Ok(())) + .never(); + + let (app, _client, cookie_client) = + TestApp::full().with_docs_rs(docs_rs_mock).with_user().await; + + let response = cookie_client + .post::<()>("/api/v1/crates/krate/0.1.0/rebuild_docs", "") + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + app.run_pending_background_jobs().await; + + Ok(()) + } +} diff --git a/src/router.rs b/src/router.rs index b589892cf96..d1f926b78aa 100644 --- a/src/router.rs +++ b/src/router.rs @@ -40,6 +40,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(version::readme::get_version_readme)) .routes(routes!(version::dependencies::get_version_dependencies)) .routes(routes!(version::downloads::get_version_downloads)) + .routes(routes!(version::docs::rebuild_version_docs)) .routes(routes!(version::authors::get_version_authors)) .routes(routes!(krate::downloads::get_crate_downloads)) .routes(routes!(krate::versions::list_versions)) diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 2f1b4601c27..c030b92ae17 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -3209,6 +3209,46 @@ expression: response.json() ] } }, + "/api/v1/crates/{name}/{version}/rebuild_docs": { + "post": { + "operationId": "rebuild_version_docs", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Trigger a rebuild for the crate documentation on docs.rs.", + "tags": [ + "versions" + ] + } + }, "/api/v1/crates/{name}/{version}/unyank": { "put": { "operationId": "unyank_version", diff --git a/src/tests/util.rs b/src/tests/util.rs index 0136cd2478e..395b34f133c 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -139,6 +139,15 @@ pub trait RequestHelper { self.run(request).await } + /// Issue a POST request + async fn post(&self, path: &str, body: impl Into) -> Response { + let request = self + .request_builder(Method::POST, path) + .with_body(body.into()); + + self.run(request).await + } + /// Issue a PUT request async fn put(&self, path: &str, body: impl Into) -> Response { let request = self diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index c53cae1b30d..b1d33e69a1d 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -11,6 +11,7 @@ use crate::tests::util::chaosproxy::ChaosProxy; use crate::tests::util::github::MOCK_GITHUB_DATA; use crate::worker::{Environment, RunnerExt}; use crate::{App, Emails, Env}; +use crates_io_docs_rs::MockDocsRsClient; use crates_io_github::MockGitHubClient; use crates_io_index::testing::UpstreamIndex; use crates_io_index::{Credentials, RepositoryConfig}; @@ -101,6 +102,7 @@ impl TestApp { use_chaos_proxy: false, team_repo: MockTeamRepo::new(), github: None, + docs_rs: None, } } @@ -242,6 +244,7 @@ pub struct TestAppBuilder { use_chaos_proxy: bool, team_repo: MockTeamRepo, github: Option, + docs_rs: Option, } impl TestAppBuilder { @@ -299,6 +302,7 @@ impl TestAppBuilder { .storage(app.storage.clone()) .deadpool(app.primary_database.clone()) .emails(app.emails.clone()) + .maybe_docs_rs(self.docs_rs.map(|cl| Box::new(cl) as _)) .team_repo(Box::new(self.team_repo)) .build(); @@ -383,6 +387,11 @@ impl TestAppBuilder { self } + pub fn with_docs_rs(mut self, docs_rs: MockDocsRsClient) -> Self { + self.docs_rs = Some(docs_rs); + self + } + pub fn with_github(mut self, github: MockGitHubClient) -> Self { self.github = Some(github); self diff --git a/src/worker/environment.rs b/src/worker/environment.rs index 842a3690a2c..30dc9fa801d 100644 --- a/src/worker/environment.rs +++ b/src/worker/environment.rs @@ -5,6 +5,7 @@ use crate::storage::Storage; use crate::typosquat; use anyhow::Context; use bon::Builder; +use crates_io_docs_rs::DocsRsClient; use crates_io_index::{Repository, RepositoryConfig}; use crates_io_team_repo::TeamRepo; use diesel_async::AsyncPgConnection; @@ -30,6 +31,7 @@ pub struct Environment { pub deadpool: Pool, pub emails: Emails, pub team_repo: Box, + pub docs_rs: Option>, /// A lazily initialised cache of the most popular crates ready to use in typosquatting checks. #[builder(skip)] diff --git a/src/worker/jobs/docs_rs_queue_rebuild.rs b/src/worker/jobs/docs_rs_queue_rebuild.rs new file mode 100644 index 00000000000..96183a20f0f --- /dev/null +++ b/src/worker/jobs/docs_rs_queue_rebuild.rs @@ -0,0 +1,57 @@ +use crate::worker::Environment; +use anyhow::anyhow; +use crates_io_docs_rs::DocsRsError; +use crates_io_worker::BackgroundJob; +use std::sync::Arc; + +/// A background job that queues a docs rebuild for a specific release +#[derive(Serialize, Deserialize)] +pub struct DocsRsQueueRebuild { + name: String, + version: String, +} + +impl DocsRsQueueRebuild { + pub fn new(name: String, version: String) -> Self { + Self { name, version } + } +} + +impl BackgroundJob for DocsRsQueueRebuild { + const JOB_NAME: &'static str = "docs_rs_queue_rebuild"; + const DEDUPLICATED: bool = true; + + type Context = Arc; + + async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> { + let Some(docs_rs) = ctx.docs_rs.as_ref() else { + warn!("docs.rs not configured, skipping rebuild"); + return Ok(()); + }; + + match docs_rs.rebuild_docs(&self.name, &self.version).await { + Ok(()) => Ok(()), + Err(DocsRsError::BadRequest(msg)) => { + warn!( + name = self.name, + version = self.version, + msg, + "couldn't queue docs rebuild" + ); + Ok(()) + } + Err(DocsRsError::RateLimited) => { + Err(anyhow!("docs rebuild request was rate limited. retrying.")) + } + Err(err) => { + error!( + name = self.name, + version = self.version, + ?err, + "couldn't queue docs rebuild. won't retry" + ); + Ok(()) + } + } + } +} diff --git a/src/worker/jobs/mod.rs b/src/worker/jobs/mod.rs index 874808db33c..f7cbd6434b8 100644 --- a/src/worker/jobs/mod.rs +++ b/src/worker/jobs/mod.rs @@ -1,6 +1,7 @@ mod archive_version_downloads; mod daily_db_maintenance; mod delete_crate; +mod docs_rs_queue_rebuild; mod downloads; pub mod dump_db; mod expiry_notification; @@ -17,6 +18,7 @@ mod update_default_version; pub use self::archive_version_downloads::ArchiveVersionDownloads; pub use self::daily_db_maintenance::DailyDbMaintenance; pub use self::delete_crate::DeleteCrateFromStorage; +pub use self::docs_rs_queue_rebuild::DocsRsQueueRebuild; pub use self::downloads::{ CleanProcessedLogFiles, ProcessCdnLog, ProcessCdnLogQueue, UpdateDownloads, }; diff --git a/src/worker/mod.rs b/src/worker/mod.rs index d72060af7a4..0838b8bff4f 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -24,6 +24,7 @@ impl RunnerExt for Runner> { .register_job_type::() .register_job_type::() .register_job_type::() + .register_job_type::() .register_job_type::() .register_job_type::() .register_job_type::()