From 148d80d1a469161532b7ad0854f45eef4f8066e5 Mon Sep 17 00:00:00 2001 From: Christian Jaeger Date: Mon, 24 Jun 2024 12:13:21 +0000 Subject: [PATCH] web/builds: API to request rebuild of a crate version This resolves #2442. - adds config variable `DOCSRS_TRIGGER_REBUILD_TOKEN` / `Config.trigger_rebuild_token` - adds `build_trigger_rebuild_handler` and route "/crate/:name/:version/rebuild" Note: does not yet contain any kind of rate limiting! --- src/build_queue.rs | 2 +- src/config.rs | 6 ++ src/web/builds.rs | 205 ++++++++++++++++++++++++++++++++++++++- src/web/crate_details.rs | 2 +- src/web/error.rs | 1 - src/web/routes.rs | 4 + 6 files changed, 214 insertions(+), 6 deletions(-) diff --git a/src/build_queue.rs b/src/build_queue.rs index 5491b0951..506acee9b 100644 --- a/src/build_queue.rs +++ b/src/build_queue.rs @@ -151,7 +151,7 @@ impl BuildQueue { .collect()) } - fn has_build_queued(&self, name: &str, version: &str) -> Result { + pub(crate) fn has_build_queued(&self, name: &str, version: &str) -> Result { Ok(self .db .get()? diff --git a/src/config.rs b/src/config.rs index a4acf0aff..c940ebc28 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,6 +41,10 @@ pub struct Config { // Gitlab authentication pub(crate) gitlab_accesstoken: Option, + // Access token for rebuild trigger at path + // "/crate/:name/:version/rebuild" + pub(crate) trigger_rebuild_token: Option, + // amount of retries for external API calls, mostly crates.io pub crates_io_api_call_retries: u32, @@ -176,6 +180,8 @@ impl Config { gitlab_accesstoken: maybe_env("DOCSRS_GITLAB_ACCESSTOKEN")?, + trigger_rebuild_token: maybe_env("DOCSRS_TRIGGER_REBUILD_TOKEN")?, + max_file_size: env("DOCSRS_MAX_FILE_SIZE", 50 * 1024 * 1024)?, max_file_size_html: env("DOCSRS_MAX_FILE_SIZE_HTML", 50 * 1024 * 1024)?, // LOL HTML only uses as much memory as the size of the start tag! diff --git a/src/web/builds.rs b/src/web/builds.rs index d79b7bbc4..37c9b621c 100644 --- a/src/web/builds.rs +++ b/src/web/builds.rs @@ -1,22 +1,33 @@ -use super::{cache::CachePolicy, error::AxumNope, headers::CanonicalUrl}; +use super::{ + cache::CachePolicy, + error::{AxumNope, JsonAxumNope, JsonAxumResult}, + headers::CanonicalUrl, +}; use crate::{ db::types::BuildStatus, docbuilder::Limits, impl_axum_webpage, + utils::spawn_blocking, web::{ + crate_details::CrateDetails, error::AxumResult, extractors::{DbConnection, Path}, match_version, MetaData, ReqVersion, }, - Config, + BuildQueue, Config, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; use axum::{ extract::Extension, http::header::ACCESS_CONTROL_ALLOW_ORIGIN, response::IntoResponse, Json, }; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, +}; use chrono::{DateTime, Utc}; use semver::Version; use serde::Serialize; +use serde_json::json; use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -111,6 +122,81 @@ pub(crate) async fn build_list_json_handler( .into_response()) } +async fn build_trigger_check( + mut conn: DbConnection, + name: &String, + version: &Version, + build_queue: &Arc, +) -> AxumResult { + let _ = CrateDetails::new(&mut *conn, &name, &version, None, vec![]) + .await? + .ok_or(AxumNope::VersionNotFound)?; + + let crate_version_is_in_queue = spawn_blocking({ + let name = name.clone(); + let version_string = version.to_string(); + let build_queue = build_queue.clone(); + move || build_queue.has_build_queued(&name, &version_string) + }) + .await?; + if crate_version_is_in_queue { + return Err(AxumNope::BadRequest(anyhow!( + "crate {name} {version} already queued for rebuild" + ))); + } + + Ok(()) +} + +// Priority according to issue #2442; positive here as it's inverted. +// FUTURE: move to a crate-global enum with all special priorities? +const TRIGGERED_REBUILD_PRIORITY: i32 = 5; + +pub(crate) async fn build_trigger_rebuild_handler( + Path((name, version)): Path<(String, Version)>, + conn: DbConnection, + Extension(build_queue): Extension>, + Extension(config): Extension>, + opt_auth_header: Option>>, +) -> JsonAxumResult { + let expected_token = + config + .trigger_rebuild_token + .as_ref() + .ok_or(JsonAxumNope(AxumNope::InternalError(anyhow!( + "access token `trigger_rebuild_token` \ + is not configured" + ))))?; + + // (Future: would it be better to have standard middleware handle auth?) + let TypedHeader(auth_header) = + opt_auth_header.ok_or(JsonAxumNope(AxumNope::MissingAuthenticationToken))?; + if auth_header.token() != expected_token { + return Err(JsonAxumNope(AxumNope::InvalidAuthenticationToken)); + } + + build_trigger_check(conn, &name, &version, &build_queue) + .await + .map_err(JsonAxumNope)?; + + spawn_blocking({ + let name = name.clone(); + let version_string = version.to_string(); + move || { + build_queue.add_crate( + &name, + &version_string, + TRIGGERED_REBUILD_PRIORITY, + None, /* because crates.io is the only service that calls this endpoint */ + ) + } + }) + .await + .map_err(|e| JsonAxumNope(e.into()))?; + + Ok(Json(json!({}))) +} + async fn get_builds( conn: &mut sqlx::PgConnection, name: &str, @@ -276,6 +362,119 @@ mod tests { }); } + #[test] + fn build_trigger_rebuild_missing_config() { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + { + let response = env.frontend().get("/crate/regex/1.3.1/rebuild").send()?; + // Needs POST + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + { + let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let text = response.text()?; + assert!(text.contains("access token `trigger_rebuild_token` is not configured")); + let json: serde_json::Value = serde_json::from_str(&text)?; + assert_eq!( + json, + serde_json::json!({ + "title": "Internal Server Error", + "message": "access token `trigger_rebuild_token` is not configured" + }) + ); + } + + Ok(()) + }) + } + + #[test] + fn build_trigger_rebuild_with_config() { + wrapper(|env| { + let correct_token = "foo137"; + env.override_config(|config| config.trigger_rebuild_token = Some(correct_token.into())); + + env.fake_release().name("foo").version("0.1.0").create()?; + + { + let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let text = response.text()?; + let json: serde_json::Value = serde_json::from_str(&text)?; + assert_eq!( + json, + serde_json::json!({ + "title": "Missing authentication token", + "message": "The token used for authentication is missing" + }) + ); + } + + { + let response = env + .frontend() + .post("/crate/regex/1.3.1/rebuild") + .bearer_auth("someinvalidtoken") + .send()?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let text = response.text()?; + let json: serde_json::Value = serde_json::from_str(&text)?; + assert_eq!( + json, + serde_json::json!({ + "title": "Invalid authentication token", + "message": "The token used for authentication is not valid" + }) + ); + } + + assert_eq!(env.build_queue().pending_count()?, 0); + assert!(!env.build_queue().has_build_queued("foo", "0.1.0")?); + + { + let response = env + .frontend() + .post("/crate/foo/0.1.0/rebuild") + .bearer_auth(correct_token) + .send()?; + assert_eq!(response.status(), StatusCode::OK); + let text = response.text()?; + let json: serde_json::Value = serde_json::from_str(&text)?; + assert_eq!(json, serde_json::json!({})); + } + + assert_eq!(env.build_queue().pending_count()?, 1); + assert!(env.build_queue().has_build_queued("foo", "0.1.0")?); + + { + let response = env + .frontend() + .post("/crate/foo/0.1.0/rebuild") + .bearer_auth(correct_token) + .send()?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let text = response.text()?; + let json: serde_json::Value = serde_json::from_str(&text)?; + assert_eq!( + json, + serde_json::json!({ + "title": "Bad request", + "message": "crate foo 0.1.0 already queued for rebuild" + }) + ); + } + + assert_eq!(env.build_queue().pending_count()?, 1); + assert!(env.build_queue().has_build_queued("foo", "0.1.0")?); + + Ok(()) + }); + } + #[test] fn build_empty_list() { wrapper(|env| { diff --git a/src/web/crate_details.rs b/src/web/crate_details.rs index d28969845..bf87280ba 100644 --- a/src/web/crate_details.rs +++ b/src/web/crate_details.rs @@ -126,7 +126,7 @@ impl CrateDetails { .unwrap()) } - async fn new( + pub(crate) async fn new( conn: &mut sqlx::PgConnection, name: &str, version: &Version, diff --git a/src/web/error.rs b/src/web/error.rs index 754b44cb8..89e731d85 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -173,7 +173,6 @@ impl ErrorResponse { }) => ( status, Json(serde_json::json!({ - "result": "err", // XXX "title": title, "message": message, })), diff --git a/src/web/routes.rs b/src/web/routes.rs index 74f42cdbb..00d80b846 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -224,6 +224,10 @@ pub(super) fn build_axum_routes() -> AxumRouter { "/crate/:name/:version/builds.json", get_internal(super::builds::build_list_json_handler), ) + .route( + "/crate/:name/:version/rebuild", + post_internal(super::builds::build_trigger_rebuild_handler), + ) .route( "/crate/:name/:version/status.json", get_internal(super::status::status_handler),