Skip to content

Commit

Permalink
web/builds: API to request rebuild of a crate version
Browse files Browse the repository at this point in the history
This resolves rust-lang#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!
  • Loading branch information
pflanze committed Jun 24, 2024
1 parent 3ba2a0f commit 148d80d
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 6 deletions.
2 changes: 1 addition & 1 deletion src/build_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ impl BuildQueue {
.collect())
}

fn has_build_queued(&self, name: &str, version: &str) -> Result<bool> {
pub(crate) fn has_build_queued(&self, name: &str, version: &str) -> Result<bool> {
Ok(self
.db
.get()?
Expand Down
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub struct Config {
// Gitlab authentication
pub(crate) gitlab_accesstoken: Option<String>,

// Access token for rebuild trigger at path
// "/crate/:name/:version/rebuild"
pub(crate) trigger_rebuild_token: Option<String>,

// amount of retries for external API calls, mostly crates.io
pub crates_io_api_call_retries: u32,

Expand Down Expand Up @@ -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!
Expand Down
205 changes: 202 additions & 3 deletions src/web/builds.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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<BuildQueue>,
) -> AxumResult<impl IntoResponse> {
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<Arc<BuildQueue>>,
Extension(config): Extension<Arc<Config>>,
opt_auth_header: Option<TypedHeader<Authorization<Bearer>>>,
) -> JsonAxumResult<impl IntoResponse> {
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,
Expand Down Expand Up @@ -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| {
Expand Down
2 changes: 1 addition & 1 deletion src/web/crate_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl CrateDetails {
.unwrap())
}

async fn new(
pub(crate) async fn new(
conn: &mut sqlx::PgConnection,
name: &str,
version: &Version,
Expand Down
1 change: 0 additions & 1 deletion src/web/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ impl ErrorResponse {
}) => (
status,
Json(serde_json::json!({
"result": "err", // XXX
"title": title,
"message": message,
})),
Expand Down
4 changes: 4 additions & 0 deletions src/web/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

0 comments on commit 148d80d

Please sign in to comment.