Skip to content

Commit 8165b09

Browse files
pflanzesyphar
authored andcommitted
web/builds: add 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!
1 parent 0ee11f6 commit 8165b09

File tree

5 files changed

+210
-5
lines changed

5 files changed

+210
-5
lines changed

src/build_queue.rs

Lines changed: 1 addition & 1 deletion
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 199 additions & 3 deletions
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)]
@@ -112,6 +124,83 @@ pub(crate) async fn build_list_json_handler(
112124
.into_response())
113125
}
114126

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

407+
#[test]
408+
fn build_trigger_rebuild_missing_config() {
409+
wrapper(|env| {
410+
env.fake_release().name("foo").version("0.1.0").create()?;
411+
412+
{
413+
let response = env.frontend().get("/crate/regex/1.3.1/rebuild").send()?;
414+
// Needs POST
415+
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
416+
}
417+
418+
{
419+
let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?;
420+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
421+
let json: serde_json::Value = response.json()?;
422+
assert_eq!(
423+
json,
424+
serde_json::json!({
425+
"title": "Unauthorized",
426+
"message": "Endpoint is not configured"
427+
})
428+
);
429+
}
430+
431+
Ok(())
432+
})
433+
}
434+
435+
#[test]
436+
fn build_trigger_rebuild_with_config() {
437+
wrapper(|env| {
438+
let correct_token = "foo137";
439+
env.override_config(|config| config.cratesio_token = Some(correct_token.into()));
440+
441+
env.fake_release().name("foo").version("0.1.0").create()?;
442+
443+
{
444+
let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?;
445+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
446+
let json: serde_json::Value = response.json()?;
447+
assert_eq!(
448+
json,
449+
serde_json::json!({
450+
"title": "Unauthorized",
451+
"message": "Missing authentication token"
452+
})
453+
);
454+
}
455+
456+
{
457+
let response = env
458+
.frontend()
459+
.post("/crate/regex/1.3.1/rebuild")
460+
.bearer_auth("someinvalidtoken")
461+
.send()?;
462+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
463+
let json: serde_json::Value = response.json()?;
464+
assert_eq!(
465+
json,
466+
serde_json::json!({
467+
"title": "Unauthorized",
468+
"message": "The token used for authentication is not valid"
469+
})
470+
);
471+
}
472+
473+
assert_eq!(env.build_queue().pending_count()?, 0);
474+
assert!(!env.build_queue().has_build_queued("foo", "0.1.0")?);
475+
476+
{
477+
let response = env
478+
.frontend()
479+
.post("/crate/foo/0.1.0/rebuild")
480+
.bearer_auth(correct_token)
481+
.send()?;
482+
assert_eq!(response.status(), StatusCode::CREATED);
483+
let json: serde_json::Value = response.json()?;
484+
assert_eq!(json, serde_json::json!({}));
485+
}
486+
487+
assert_eq!(env.build_queue().pending_count()?, 1);
488+
assert!(env.build_queue().has_build_queued("foo", "0.1.0")?);
489+
490+
{
491+
let response = env
492+
.frontend()
493+
.post("/crate/foo/0.1.0/rebuild")
494+
.bearer_auth(correct_token)
495+
.send()?;
496+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
497+
let json: serde_json::Value = response.json()?;
498+
assert_eq!(
499+
json,
500+
serde_json::json!({
501+
"title": "Bad request",
502+
"message": "crate foo 0.1.0 already queued for rebuild"
503+
})
504+
);
505+
}
506+
507+
assert_eq!(env.build_queue().pending_count()?, 1);
508+
assert!(env.build_queue().has_build_queued("foo", "0.1.0")?);
509+
510+
Ok(())
511+
});
512+
}
513+
318514
#[test]
319515
fn build_empty_list() {
320516
wrapper(|env| {

src/web/crate_details.rs

Lines changed: 1 addition & 1 deletion
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

Lines changed: 4 additions & 0 deletions
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)