Skip to content

Commit 148d80d

Browse files
committed
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!
1 parent 3ba2a0f commit 148d80d

File tree

6 files changed

+214
-6
lines changed

6 files changed

+214
-6
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ pub struct Config {
4141
// Gitlab authentication
4242
pub(crate) gitlab_accesstoken: Option<String>,
4343

44+
// Access token for rebuild trigger at path
45+
// "/crate/:name/:version/rebuild"
46+
pub(crate) trigger_rebuild_token: Option<String>,
47+
4448
// amount of retries for external API calls, mostly crates.io
4549
pub crates_io_api_call_retries: u32,
4650

@@ -176,6 +180,8 @@ impl Config {
176180

177181
gitlab_accesstoken: maybe_env("DOCSRS_GITLAB_ACCESSTOKEN")?,
178182

183+
trigger_rebuild_token: maybe_env("DOCSRS_TRIGGER_REBUILD_TOKEN")?,
184+
179185
max_file_size: env("DOCSRS_MAX_FILE_SIZE", 50 * 1024 * 1024)?,
180186
max_file_size_html: env("DOCSRS_MAX_FILE_SIZE_HTML", 50 * 1024 * 1024)?,
181187
// LOL HTML only uses as much memory as the size of the start tag!

src/web/builds.rs

Lines changed: 202 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
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};
1828
use semver::Version;
1929
use serde::Serialize;
30+
use serde_json::json;
2031
use std::sync::Arc;
2132

2233
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -111,6 +122,81 @@ pub(crate) async fn build_list_json_handler(
111122
.into_response())
112123
}
113124

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

365+
#[test]
366+
fn build_trigger_rebuild_missing_config() {
367+
wrapper(|env| {
368+
env.fake_release().name("foo").version("0.1.0").create()?;
369+
370+
{
371+
let response = env.frontend().get("/crate/regex/1.3.1/rebuild").send()?;
372+
// Needs POST
373+
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
374+
}
375+
376+
{
377+
let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?;
378+
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
379+
let text = response.text()?;
380+
assert!(text.contains("access token `trigger_rebuild_token` is not configured"));
381+
let json: serde_json::Value = serde_json::from_str(&text)?;
382+
assert_eq!(
383+
json,
384+
serde_json::json!({
385+
"title": "Internal Server Error",
386+
"message": "access token `trigger_rebuild_token` is not configured"
387+
})
388+
);
389+
}
390+
391+
Ok(())
392+
})
393+
}
394+
395+
#[test]
396+
fn build_trigger_rebuild_with_config() {
397+
wrapper(|env| {
398+
let correct_token = "foo137";
399+
env.override_config(|config| config.trigger_rebuild_token = Some(correct_token.into()));
400+
401+
env.fake_release().name("foo").version("0.1.0").create()?;
402+
403+
{
404+
let response = env.frontend().post("/crate/regex/1.3.1/rebuild").send()?;
405+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
406+
let text = response.text()?;
407+
let json: serde_json::Value = serde_json::from_str(&text)?;
408+
assert_eq!(
409+
json,
410+
serde_json::json!({
411+
"title": "Missing authentication token",
412+
"message": "The token used for authentication is missing"
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 text = response.text()?;
425+
let json: serde_json::Value = serde_json::from_str(&text)?;
426+
assert_eq!(
427+
json,
428+
serde_json::json!({
429+
"title": "Invalid authentication token",
430+
"message": "The token used for authentication is not valid"
431+
})
432+
);
433+
}
434+
435+
assert_eq!(env.build_queue().pending_count()?, 0);
436+
assert!(!env.build_queue().has_build_queued("foo", "0.1.0")?);
437+
438+
{
439+
let response = env
440+
.frontend()
441+
.post("/crate/foo/0.1.0/rebuild")
442+
.bearer_auth(correct_token)
443+
.send()?;
444+
assert_eq!(response.status(), StatusCode::OK);
445+
let text = response.text()?;
446+
let json: serde_json::Value = serde_json::from_str(&text)?;
447+
assert_eq!(json, serde_json::json!({}));
448+
}
449+
450+
assert_eq!(env.build_queue().pending_count()?, 1);
451+
assert!(env.build_queue().has_build_queued("foo", "0.1.0")?);
452+
453+
{
454+
let response = env
455+
.frontend()
456+
.post("/crate/foo/0.1.0/rebuild")
457+
.bearer_auth(correct_token)
458+
.send()?;
459+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
460+
let text = response.text()?;
461+
let json: serde_json::Value = serde_json::from_str(&text)?;
462+
assert_eq!(
463+
json,
464+
serde_json::json!({
465+
"title": "Bad request",
466+
"message": "crate foo 0.1.0 already queued for rebuild"
467+
})
468+
);
469+
}
470+
471+
assert_eq!(env.build_queue().pending_count()?, 1);
472+
assert!(env.build_queue().has_build_queued("foo", "0.1.0")?);
473+
474+
Ok(())
475+
});
476+
}
477+
279478
#[test]
280479
fn build_empty_list() {
281480
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/error.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,6 @@ impl ErrorResponse {
173173
}) => (
174174
status,
175175
Json(serde_json::json!({
176-
"result": "err", // XXX
177176
"title": title,
178177
"message": message,
179178
})),

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)