diff --git a/src/app.rs b/src/app.rs index 75212a95e66..6d56a488cae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -69,6 +69,11 @@ impl App { (_, Env::Test) => 1, _ => 30, }; + let read_only_mode = dotenv::var("READ_ONLY_MODE").is_ok(); + let connection_config = db::ConnectionConfig { + statement_timeout: db_connection_timeout, + read_only: read_only_mode, + }; let thread_pool = Arc::new(ScheduledThreadPool::new(db_helper_threads)); @@ -76,7 +81,7 @@ impl App { .max_size(db_pool_size) .min_idle(db_min_idle) .connection_timeout(Duration::from_secs(db_connection_timeout)) - .connection_customizer(Box::new(db::SetStatementTimeout(db_connection_timeout))) + .connection_customizer(Box::new(connection_config)) .thread_pool(thread_pool); App { diff --git a/src/db.rs b/src/db.rs index 214266f48a9..831ea767696 100644 --- a/src/db.rs +++ b/src/db.rs @@ -100,15 +100,26 @@ impl RequestTransaction for T { } #[derive(Debug, Clone, Copy)] -pub struct SetStatementTimeout(pub u64); +pub struct ConnectionConfig { + pub statement_timeout: u64, + pub read_only: bool, +} -impl CustomizeConnection for SetStatementTimeout { +impl CustomizeConnection for ConnectionConfig { fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), r2d2::Error> { use diesel::sql_query; - sql_query(format!("SET statement_timeout = {}", self.0 * 1000)) - .execute(conn) - .map_err(r2d2::Error::QueryError)?; + sql_query(format!( + "SET statement_timeout = {}", + self.statement_timeout * 1000 + )) + .execute(conn) + .map_err(r2d2::Error::QueryError)?; + if self.read_only { + sql_query("SET default_transaction_read_only = 't'") + .execute(conn) + .map_err(r2d2::Error::QueryError)?; + } Ok(()) } } diff --git a/src/git.rs b/src/git.rs index 00b598a07b3..07f454c85a9 100644 --- a/src/git.rs +++ b/src/git.rs @@ -12,7 +12,7 @@ use url::Url; use crate::background_jobs::Environment; use crate::models::{DependencyKind, Version}; use crate::schema::versions; -use crate::util::errors::{internal, std_error_no_send, CargoResult}; +use crate::util::errors::{std_error_no_send, CargoError, CargoResult}; #[derive(Serialize, Deserialize, Debug)] pub struct Crate { @@ -159,7 +159,9 @@ impl Job for AddCrate { } pub fn add_crate(conn: &PgConnection, krate: Crate) -> CargoResult<()> { - AddCrate { krate }.enqueue(conn).map_err(|e| internal(&e)) + AddCrate { krate } + .enqueue(conn) + .map_err(|e| CargoError::from_std_error(e)) } #[derive(Serialize, Deserialize)] @@ -239,5 +241,5 @@ pub fn yank(conn: &PgConnection, krate: String, version: Version, yanked: bool) yanked, } .enqueue(conn) - .map_err(|e| internal(&e)) + .map_err(|e| CargoError::from_std_error(e)) } diff --git a/src/middleware/current_user.rs b/src/middleware/current_user.rs index 577d1ac2040..8e96214d3c8 100644 --- a/src/middleware/current_user.rs +++ b/src/middleware/current_user.rs @@ -45,7 +45,9 @@ impl Middleware for CurrentUser { // Otherwise, look for an `Authorization` header on the request // and try to find a user in the database with a matching API token let user = if let Some(headers) = req.headers().find("Authorization") { - User::find_by_api_token(&conn, headers[0]).ok() + User::find_by_api_token(&conn, headers[0]) + .optional() + .map_err(|e| Box::new(e) as Box)? } else { None }; diff --git a/src/middleware/run_pending_background_jobs.rs b/src/middleware/run_pending_background_jobs.rs index ee120503db6..5023aee7a7a 100644 --- a/src/middleware/run_pending_background_jobs.rs +++ b/src/middleware/run_pending_background_jobs.rs @@ -13,6 +13,10 @@ impl Middleware for RunPendingBackgroundJobs { req: &mut dyn Request, res: Result>, ) -> Result> { + if response_is_error(&res) { + return res; + } + let app = req.app(); let connection_pool = app.diesel_database.clone(); let repo = Repository::open(&app.config.index_location).expect("Could not clone index"); @@ -28,3 +32,10 @@ impl Middleware for RunPendingBackgroundJobs { res } } + +fn response_is_error(res: &Result>) -> bool { + match res { + Ok(res) => res.status.0 >= 400, + Err(_) => true, + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 5efc0a00d1f..f40dbded535 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -106,18 +106,26 @@ impl<'a> NewUser<'a> { impl User { /// Queries the database for a user with a certain `api_token` value. - pub fn find_by_api_token(conn: &PgConnection, token_: &str) -> CargoResult { + pub fn find_by_api_token(conn: &PgConnection, token_: &str) -> QueryResult { use crate::schema::api_tokens::dsl::{api_tokens, last_used_at, revoked, token, user_id}; - use crate::schema::users::dsl::{id, users}; use diesel::update; + let tokens = api_tokens .filter(token.eq(token_)) .filter(revoked.eq(false)); - let user_id_ = update(tokens) - .set(last_used_at.eq(now.nullable())) - .returning(user_id) - .get_result::(conn)?; - Ok(users.filter(id.eq(user_id_)).get_result(conn)?) + + // If the database is in read only mode, we can't update last_used_at. + // Try updating in a new transaction, if that fails, fall back to reading + let user_id_ = conn + .transaction(|| { + update(tokens) + .set(last_used_at.eq(now.nullable())) + .returning(user_id) + .get_result::(conn) + }) + .or_else(|_| tokens.select(user_id).first(conn))?; + + users::table.find(user_id_).first(conn) } pub fn owning(krate: &Crate, conn: &PgConnection) -> CargoResult> { diff --git a/src/tests/all.rs b/src/tests/all.rs index 7b184a29139..35dea7a5aff 100644 --- a/src/tests/all.rs +++ b/src/tests/all.rs @@ -68,6 +68,7 @@ mod git; mod keyword; mod krate; mod owners; +mod read_only_mode; mod record; mod schema_details; mod server; diff --git a/src/tests/read_only_mode.rs b/src/tests/read_only_mode.rs new file mode 100644 index 00000000000..55e59608b35 --- /dev/null +++ b/src/tests/read_only_mode.rs @@ -0,0 +1,56 @@ +use crate::builders::CrateBuilder; +use crate::{RequestHelper, TestApp}; +use diesel::prelude::*; + +#[test] +fn can_hit_read_only_endpoints_in_read_only_mode() { + let (app, anon) = TestApp::init().empty(); + app.db(set_read_only).unwrap(); + anon.get::<()>("/api/v1/crates").assert_status(200); +} + +#[test] +fn cannot_hit_endpoint_which_writes_db_in_read_only_mode() { + let (app, _, user, token) = TestApp::init().with_token(); + app.db(|conn| { + CrateBuilder::new("foo_yank_read_only", user.as_model().id) + .version("1.0.0") + .expect_build(conn); + set_read_only(conn).unwrap(); + }); + token + .delete::<()>("/api/v1/crates/foo_yank_read_only/1.0.0/yank") + .assert_status(503); +} + +#[test] +#[ignore] // Will be implicitly fixed by #1387, no need to special case here +fn can_download_crate_in_read_only_mode() { + let (app, anon, user) = TestApp::with_proxy().with_user(); + + app.db(|conn| { + CrateBuilder::new("foo_download_read_only", user.as_model().id) + .version("1.0.0") + .expect_build(conn); + set_read_only(conn).unwrap(); + }); + + anon.get::<()>("/api/v1/crates/foo_download_read_only/1.0.0/download") + .assert_status(302); + + // We're in read only mode so the download should not have been counted + app.db(|conn| { + use cargo_registry::schema::version_downloads::dsl::*; + use diesel::dsl::sum; + + let dl_count = version_downloads + .select(sum(downloads)) + .get_result::>(conn); + assert_eq!(Ok(None), dl_count); + }) +} + +fn set_read_only(conn: &PgConnection) -> QueryResult<()> { + diesel::sql_query("SET TRANSACTION READ ONLY").execute(conn)?; + Ok(()) +} diff --git a/src/util/errors.rs b/src/util/errors.rs index 85455639bd0..49454a1d6e9 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -49,6 +49,22 @@ impl dyn CargoError { pub fn is(&self) -> bool { self.get_type_id() == TypeId::of::() } + + pub fn from_std_error(err: Box) -> Box { + Self::try_convert(&*err).unwrap_or_else(|| internal(&err)) + } + + fn try_convert(err: &(dyn Error + Send + 'static)) -> Option> { + match err.downcast_ref() { + Some(DieselError::NotFound) => Some(Box::new(NotFound)), + Some(DieselError::DatabaseError(_, info)) + if info.message().ends_with("read-only transaction") => + { + Some(Box::new(ReadOnlyMode)) + } + _ => None, + } + } } impl CargoError for Box { @@ -155,13 +171,9 @@ impl CargoError for E { } } -impl From for Box { +impl From for Box { fn from(err: E) -> Box { - if let Some(DieselError::NotFound) = Any::downcast_ref::(&err) { - Box::new(NotFound) - } else { - Box::new(err) - } + CargoError::try_convert(&err).unwrap_or_else(|| Box::new(err)) } } // ============================================================================= @@ -340,3 +352,34 @@ pub fn std_error(e: Box) -> Box { pub fn std_error_no_send(e: Box) -> Box { Box::new(CargoErrToStdErr(e)) } + +#[derive(Debug, Clone, Copy)] +pub struct ReadOnlyMode; + +impl CargoError for ReadOnlyMode { + fn description(&self) -> &str { + "tried to write in read only mode" + } + + fn response(&self) -> Option { + let mut response = json_response(&Bad { + errors: vec![StringError { + detail: "Crates.io is currently in read-only mode for maintenance. \ + Please try again later." + .to_string(), + }], + }); + response.status = (503, "Service Unavailable"); + Some(response) + } + + fn human(&self) -> bool { + true + } +} + +impl fmt::Display for ReadOnlyMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + "Tried to write in read only mode".fmt(f) + } +}