Skip to content

enable crate maintainers to trigger docs.rs rebuilds from crates.io #11169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/config/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub struct Server {
pub html_render_cache_max_capacity: u64,

pub content_security_policy: Option<HeaderValue>,

pub docs_rs_url: url::Url,
pub docs_rs_api_token: Option<String>,
}

impl Server {
Expand Down Expand Up @@ -233,6 +236,9 @@ impl Server {
og_image_base_url: var_parsed("OG_IMAGE_BASE_URL")?,
html_render_cache_max_capacity: var_parsed("HTML_RENDER_CACHE_CAP")?.unwrap_or(1024),
content_security_policy: Some(content_security_policy.parse()?),
docs_rs_url: var_parsed("DOCS_RS_HOSTNAME")?
.unwrap_or_else(|| url::Url::parse("https://docs.rs").unwrap()),
docs_rs_api_token: var("DOCS_RS_API_TOKEN")?,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions src/controllers/version.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod authors;
pub mod dependencies;
pub mod docs;
pub mod downloads;
pub mod metadata;
pub mod readme;
Expand Down
50 changes: 50 additions & 0 deletions src/controllers/version/docs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! Endpoint for triggering a docs.rs rebuild

use super::CrateVersionPath;
use crate::app::AppState;
use crate::auth::AuthCheck;
use crate::util::errors::{AppResult, forbidden};
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<Response> {
if app.config.docs_rs_api_token.is_none() {
return Err(forbidden("docs.rs integration is not configured"));
};

let mut conn = app.db_write().await?;
AuthCheck::only_cookie().check(&req, &mut conn).await?;

// validate if version & crate exist
path.load_version_and_crate(&mut conn).await?;

if let Err(error) = jobs::DocsRsQueueRebuild::new(path.name, path.version)
.enqueue(&mut conn)
.await
{
error!(
?error,
"docs_rs_queue_rebuild: Failed to enqueue background job"
);
}

Ok(StatusCode::CREATED.into_response())
}
101 changes: 101 additions & 0 deletions src/docs_rs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use async_trait::async_trait;
use http::StatusCode;
use mockall::automock;
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),
}

#[automock]
#[async_trait]
pub trait DocsRsClient: Send + Sync {
async fn rebuild_docs(&self, name: &str, version: &str) -> Result<(), DocsRsError>;
}

pub(crate) struct RealDocsRsClient {
client: reqwest::Client,
base_url: Url,
api_token: String,
}

impl RealDocsRsClient {
pub fn new(base_url: impl Into<Url>, api_token: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.into(),
api_token: api_token.into(),
}
}
}

#[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()
))),
}
}
}

/// Builds an [DocsRsClient] implementation based on the [crate::config::Server]
pub fn docs_rs_client(config: &crate::config::Server) -> Box<dyn DocsRsClient + Send + Sync> {
if let Some(api_token) = &config.docs_rs_api_token {
Box::new(RealDocsRsClient::new(config.docs_rs_url.clone(), api_token))
} else {
Box::new(MockDocsRsClient::new())
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod cloudfront;
pub mod config;
pub mod controllers;
pub mod db;
pub mod docs_rs;
pub mod email;
pub mod external_urls;
pub mod fastly;
Expand Down
3 changes: 3 additions & 0 deletions src/tests/util/test_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use std::sync::LazyLock;
use std::{rc::Rc, sync::Arc, time::Duration};
use tokio::runtime::Handle;
use tokio::task::block_in_place;
use url::Url;

struct TestAppInner {
app: Arc<App>,
Expand Down Expand Up @@ -482,6 +483,8 @@ fn simple_config() -> config::Server {
og_image_base_url: None,
html_render_cache_max_capacity: 1024,
content_security_policy: None,
docs_rs_url: Url::parse("https://docs.rs").unwrap(),
docs_rs_api_token: None,
}
}

Expand Down
53 changes: 53 additions & 0 deletions src/worker/jobs/docs_rs_queue_rebuild.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use crate::docs_rs::{DocsRsError, docs_rs_client};
use crate::worker::Environment;
use anyhow::anyhow;
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<Environment>;

async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> {
let client = docs_rs_client(&ctx.config);
match client.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(())
}
}
}
}
2 changes: 2 additions & 0 deletions src/worker/jobs/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions src/worker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ impl RunnerExt for Runner<Arc<Environment>> {
.register_job_type::<jobs::CleanProcessedLogFiles>()
.register_job_type::<jobs::DailyDbMaintenance>()
.register_job_type::<jobs::DeleteCrateFromStorage>()
.register_job_type::<jobs::DocsRsQueueRebuild>()
.register_job_type::<jobs::DumpDb>()
.register_job_type::<jobs::IndexVersionDownloadsArchive>()
.register_job_type::<jobs::InvalidateCdns>()
Expand Down