Skip to content

Commit 464a4c0

Browse files
committed
web/builds: add API to request rebuild of a crate version
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!
1 parent cf58d70 commit 464a4c0

File tree

5 files changed

+210
-5
lines changed

5 files changed

+210
-5
lines changed

src/build_queue.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ impl BuildQueue {
151151
.collect())
152152
}
153153

154-
fn has_build_queued(&self, name: &str, version: &str) -> Result<bool> {
154+
pub(crate) fn has_build_queued(&self, name: &str, version: &str) -> Result<bool> {
155155
Ok(self
156156
.db
157157
.get()?

src/config.rs

+5
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ pub struct Config {
4141
// Gitlab authentication
4242
pub(crate) gitlab_accesstoken: Option<String>,
4343

44+
// Access token for APIs for crates.io
45+
pub(crate) cratesio_token: Option<String>,
46+
4447
// amount of retries for external API calls, mostly crates.io
4548
pub crates_io_api_call_retries: u32,
4649

@@ -176,6 +179,8 @@ impl Config {
176179

177180
gitlab_accesstoken: maybe_env("DOCSRS_GITLAB_ACCESSTOKEN")?,
178181

182+
cratesio_token: maybe_env("DOCSRS_CRATESIO_TOKEN")?,
183+
179184
max_file_size: env("DOCSRS_MAX_FILE_SIZE", 50 * 1024 * 1024)?,
180185
max_file_size_html: env("DOCSRS_MAX_FILE_SIZE_HTML", 50 * 1024 * 1024)?,
181186
// LOL HTML only uses as much memory as the size of the start tag!

src/web/builds.rs

+199-3
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
1-
use super::{cache::CachePolicy, error::AxumNope, headers::CanonicalUrl};
1+
use super::{
2+
cache::CachePolicy,
3+
error::{AxumNope, JsonAxumNope, JsonAxumResult},
4+
headers::CanonicalUrl,
5+
};
26
use crate::{
37
db::types::BuildStatus,
48
docbuilder::Limits,
59
impl_axum_webpage,
10+
utils::spawn_blocking,
611
web::{
12+
crate_details::CrateDetails,
713
error::AxumResult,
814
extractors::{DbConnection, Path},
915
match_version, MetaData, ReqVersion,
1016
},
11-
Config,
17+
BuildQueue, Config,
1218
};
13-
use anyhow::Result;
19+
use anyhow::{anyhow, Result};
1420
use axum::{
1521
extract::Extension, http::header::ACCESS_CONTROL_ALLOW_ORIGIN, response::IntoResponse, Json,
1622
};
23+
use axum_extra::{
24+
headers::{authorization::Bearer, Authorization},
25+
TypedHeader,
26+
};
1727
use chrono::{DateTime, Utc};
28+
use http::StatusCode;
1829
use semver::Version;
1930
use serde::Serialize;
31+
use serde_json::json;
2032
use std::sync::Arc;
2133

2234
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -111,6 +123,83 @@ pub(crate) async fn build_list_json_handler(
111123
.into_response())
112124
}
113125

126+
async fn build_trigger_check(
127+
mut conn: DbConnection,
128+
name: &String,
129+
version: &Version,
130+
build_queue: &Arc<BuildQueue>,
131+
) -> AxumResult<impl IntoResponse> {
132+
let _ = CrateDetails::new(&mut *conn, &name, &version, None, vec![])
133+
.await?
134+
.ok_or(AxumNope::VersionNotFound)?;
135+
136+
let crate_version_is_in_queue = spawn_blocking({
137+
let name = name.clone();
138+
let version_string = version.to_string();
139+
let build_queue = build_queue.clone();
140+
move || build_queue.has_build_queued(&name, &version_string)
141+
})
142+
.await?;
143+
if crate_version_is_in_queue {
144+
return Err(AxumNope::BadRequest(anyhow!(
145+
"crate {name} {version} already queued for rebuild"
146+
)));
147+
}
148+
149+
Ok(())
150+
}
151+
152+
// Priority according to issue #2442; positive here as it's inverted.
153+
// FUTURE: move to a crate-global enum with all special priorities?
154+
const TRIGGERED_REBUILD_PRIORITY: i32 = 5;
155+
156+
pub(crate) async fn build_trigger_rebuild_handler(
157+
Path((name, version)): Path<(String, Version)>,
158+
conn: DbConnection,
159+
Extension(build_queue): Extension<Arc<BuildQueue>>,
160+
Extension(config): Extension<Arc<Config>>,
161+
opt_auth_header: Option<TypedHeader<Authorization<Bearer>>>,
162+
) -> JsonAxumResult<impl IntoResponse> {
163+
let expected_token =
164+
config
165+
.cratesio_token
166+
.as_ref()
167+
.ok_or(JsonAxumNope(AxumNope::Unauthorized(
168+
"Endpoint is not configured",
169+
)))?;
170+
171+
// (Future: would it be better to have standard middleware handle auth?)
172+
let TypedHeader(auth_header) = opt_auth_header.ok_or(JsonAxumNope(AxumNope::Unauthorized(
173+
"Missing authentication token",
174+
)))?;
175+
if auth_header.token() != expected_token {
176+
return Err(JsonAxumNope(AxumNope::Unauthorized(
177+
"The token used for authentication is not valid",
178+
)));
179+
}
180+
181+
build_trigger_check(conn, &name, &version, &build_queue)
182+
.await
183+
.map_err(JsonAxumNope)?;
184+
185+
spawn_blocking({
186+
let name = name.clone();
187+
let version_string = version.to_string();
188+
move || {
189+
build_queue.add_crate(
190+
&name,
191+
&version_string,
192+
TRIGGERED_REBUILD_PRIORITY,
193+
None, /* because crates.io is the only service that calls this endpoint */
194+
)
195+
}
196+
})
197+
.await
198+
.map_err(|e| JsonAxumNope(e.into()))?;
199+
200+
Ok((StatusCode::CREATED, Json(json!({}))))
201+
}
202+
114203
async fn get_builds(
115204
conn: &mut sqlx::PgConnection,
116205
name: &str,
@@ -276,6 +365,113 @@ mod tests {
276365
});
277366
}
278367

368+
#[test]
369+
fn build_trigger_rebuild_missing_config() {
370+
wrapper(|env| {
371+
env.fake_release().name("foo").version("0.1.0").create()?;
372+
373+
{
374+
let response = env.frontend().get("/crate/regex/1.3.1/rebuild").send()?;
375+
// Needs POST
376+
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
377+
}
378+
379+
{
380+
let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?;
381+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
382+
let json: serde_json::Value = response.json()?;
383+
assert_eq!(
384+
json,
385+
serde_json::json!({
386+
"title": "Unauthorized",
387+
"message": "Endpoint is not configured"
388+
})
389+
);
390+
}
391+
392+
Ok(())
393+
})
394+
}
395+
396+
#[test]
397+
fn build_trigger_rebuild_with_config() {
398+
wrapper(|env| {
399+
let correct_token = "foo137";
400+
env.override_config(|config| config.cratesio_token = Some(correct_token.into()));
401+
402+
env.fake_release().name("foo").version("0.1.0").create()?;
403+
404+
{
405+
let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?;
406+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
407+
let json: serde_json::Value = response.json()?;
408+
assert_eq!(
409+
json,
410+
serde_json::json!({
411+
"title": "Unauthorized",
412+
"message": "Missing authentication token"
413+
})
414+
);
415+
}
416+
417+
{
418+
let response = env
419+
.frontend()
420+
.post("/crate/regex/1.3.1/rebuild")
421+
.bearer_auth("someinvalidtoken")
422+
.send()?;
423+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
424+
let json: serde_json::Value = response.json()?;
425+
assert_eq!(
426+
json,
427+
serde_json::json!({
428+
"title": "Unauthorized",
429+
"message": "The token used for authentication is not valid"
430+
})
431+
);
432+
}
433+
434+
assert_eq!(env.build_queue().pending_count()?, 0);
435+
assert!(!env.build_queue().has_build_queued("foo", "0.1.0")?);
436+
437+
{
438+
let response = env
439+
.frontend()
440+
.post("/crate/foo/0.1.0/rebuild")
441+
.bearer_auth(correct_token)
442+
.send()?;
443+
assert_eq!(response.status(), StatusCode::CREATED);
444+
let json: serde_json::Value = response.json()?;
445+
assert_eq!(json, serde_json::json!({}));
446+
}
447+
448+
assert_eq!(env.build_queue().pending_count()?, 1);
449+
assert!(env.build_queue().has_build_queued("foo", "0.1.0")?);
450+
451+
{
452+
let response = env
453+
.frontend()
454+
.post("/crate/foo/0.1.0/rebuild")
455+
.bearer_auth(correct_token)
456+
.send()?;
457+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
458+
let json: serde_json::Value = response.json()?;
459+
assert_eq!(
460+
json,
461+
serde_json::json!({
462+
"title": "Bad request",
463+
"message": "crate foo 0.1.0 already queued for rebuild"
464+
})
465+
);
466+
}
467+
468+
assert_eq!(env.build_queue().pending_count()?, 1);
469+
assert!(env.build_queue().has_build_queued("foo", "0.1.0")?);
470+
471+
Ok(())
472+
});
473+
}
474+
279475
#[test]
280476
fn build_empty_list() {
281477
wrapper(|env| {

src/web/crate_details.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ impl CrateDetails {
126126
.unwrap())
127127
}
128128

129-
async fn new(
129+
pub(crate) async fn new(
130130
conn: &mut sqlx::PgConnection,
131131
name: &str,
132132
version: &Version,

src/web/routes.rs

+4
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ pub(super) fn build_axum_routes() -> AxumRouter {
224224
"/crate/:name/:version/builds.json",
225225
get_internal(super::builds::build_list_json_handler),
226226
)
227+
.route(
228+
"/crate/:name/:version/rebuild",
229+
post_internal(super::builds::build_trigger_rebuild_handler),
230+
)
227231
.route(
228232
"/crate/:name/:version/status.json",
229233
get_internal(super::status::status_handler),

0 commit comments

Comments
 (0)