Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add API for crates.io to trigger rebuilds #2534

Merged
merged 8 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ chrono = { version = "0.4.11", default-features = false, features = ["clock", "s
# Transitive dependencies we don't use directly but need to have specific versions of
thread_local = "1.1.3"
humantime = "2.1.0"
constant_time_eq = "0.3.0"

[dependencies.postgres]
version = "0.19"
Expand Down
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 APIs for crates.io (careful: use
// constant_time_eq for comparisons!)
pub(crate) cratesio_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")?,

cratesio_token: maybe_env("DOCSRS_CRATESIO_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
6 changes: 6 additions & 0 deletions src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,12 @@ impl TestFrontend {
self.client.request(Method::GET, url)
}

pub(crate) fn post(&self, url: &str) -> RequestBuilder {
let url = self.build_url(url);
debug!("posting {url}");
self.client.request(Method::POST, url)
}

pub(crate) fn get_no_redirect(&self, url: &str) -> RequestBuilder {
let url = self.build_url(url);
debug!("getting {url} (no redirects)");
Expand Down
222 changes: 219 additions & 3 deletions src/web/builds.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
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::{
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 constant_time_eq::constant_time_eq;
use http::StatusCode;
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 +123,103 @@ pub(crate) async fn build_list_json_handler(
.into_response())
}

async fn crate_version_exists(
mut conn: DbConnection,
name: &String,
version: &Version,
) -> Result<bool, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT 1 as "dummy"
FROM releases
INNER JOIN crates ON crates.id = releases.crate_id
WHERE crates.name = $1 AND releases.version = $2
LIMIT 1"#,
name,
version.to_string(),
)
.fetch_optional(&mut *conn)
.await?;
Ok(row.is_some())
}

async fn build_trigger_check(
conn: DbConnection,
name: &String,
version: &Version,
build_queue: &Arc<BuildQueue>,
) -> AxumResult<impl IntoResponse> {
if !crate_version_exists(conn, name, version).await? {
return Err(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
.cratesio_token
.as_ref()
.ok_or(JsonAxumNope(AxumNope::Unauthorized(
"Endpoint 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::Unauthorized(
pflanze marked this conversation as resolved.
Show resolved Hide resolved
"Missing authentication token",
)))?;
if !constant_time_eq(auth_header.token().as_bytes(), expected_token.as_bytes()) {
return Err(JsonAxumNope(AxumNope::Unauthorized(
"The token used for authentication is not valid",
)));
}

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((StatusCode::CREATED, Json(json!({}))))
}

async fn get_builds(
conn: &mut sqlx::PgConnection,
name: &str,
Expand Down Expand Up @@ -276,6 +385,113 @@ 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::UNAUTHORIZED);
let json: serde_json::Value = response.json()?;
assert_eq!(
json,
serde_json::json!({
"title": "Unauthorized",
"message": "Endpoint is not configured"
})
);
}

Ok(())
})
}

#[test]
fn build_trigger_rebuild_with_config() {
wrapper(|env| {
let correct_token = "foo137";
env.override_config(|config| config.cratesio_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 json: serde_json::Value = response.json()?;
assert_eq!(
json,
serde_json::json!({
"title": "Unauthorized",
"message": "Missing authentication token"
})
);
}

{
let response = env
.frontend()
.post("/crate/regex/1.3.1/rebuild")
.bearer_auth("someinvalidtoken")
.send()?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let json: serde_json::Value = response.json()?;
assert_eq!(
json,
serde_json::json!({
"title": "Unauthorized",
"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::CREATED);
let json: serde_json::Value = response.json()?;
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 json: serde_json::Value = response.json()?;
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
Loading
Loading