From 8b35a946d9f6b31b26b9783acbfab984316051f4 Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Fri, 23 Feb 2024 17:09:47 +0100 Subject: [PATCH 001/192] Allow external HTTP client --- src/k2v-client/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/k2v-client/lib.rs b/src/k2v-client/lib.rs index 852274a7..5b6d7f58 100644 --- a/src/k2v-client/lib.rs +++ b/src/k2v-client/lib.rs @@ -72,6 +72,16 @@ impl K2vClient { .enable_http2() .build(); let client = HttpClient::builder(TokioExecutor::new()).build(connector); + Self::new_with_client(config, client) + } + + /// Create a new K2V client with an external client. + /// Useful for example if you plan on creating many clients but you want to mutualize the + /// underlying thread pools & co. + pub fn new_with_client( + config: K2vClientConfig, + client: HttpClient, Body>, + ) -> Result { let user_agent: std::borrow::Cow = match &config.user_agent { Some(ua) => ua.into(), None => format!("k2v/{}", env!("CARGO_PKG_VERSION")).into(), From c9b733a4a667c82c665d84352624902dcba093a7 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 14 Dec 2024 17:46:27 +0100 Subject: [PATCH 002/192] support redirection on s3 endpoint --- src/api/admin/bucket.rs | 1 + src/api/s3/get.rs | 44 ++++-- src/api/s3/website.rs | 131 ++++++++++++---- src/garage/admin/bucket.rs | 1 + src/model/bucket_table.rs | 54 +++++++ src/web/web_server.rs | 312 +++++++++++++++++++++++++++++-------- 6 files changed, 445 insertions(+), 98 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ac3cba00..d5fd0e6b 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -423,6 +423,7 @@ pub async fn handle_update_bucket( "Please specify indexDocument when enabling website access.", )?, error_document: wa.error_document, + routing_rules: Vec::new(), })); } else { if wa.index_document.is_some() || wa.error_document.is_some() { diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index f5d3cf11..eea3434e 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -163,7 +163,15 @@ pub async fn handle_head( key: &str, part_number: Option, ) -> Result, Error> { - handle_head_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number).await + handle_head_without_ctx( + ctx.garage, + req, + ctx.bucket_id, + key, + StatusCode::OK, + part_number, + ) + .await } /// Handle HEAD request for website @@ -172,6 +180,7 @@ pub async fn handle_head_without_ctx( req: &Request, bucket_id: Uuid, key: &str, + status_code: StatusCode, part_number: Option, ) -> Result, Error> { let object = garage @@ -272,7 +281,7 @@ pub async fn handle_head_without_ctx( checksum_mode, ) .header(CONTENT_LENGTH, format!("{}", version_meta.size)) - .status(StatusCode::OK) + .status(status_code) .body(empty_body())?) } } @@ -285,7 +294,16 @@ pub async fn handle_get( part_number: Option, overrides: GetObjectOverrides, ) -> Result, Error> { - handle_get_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number, overrides).await + handle_get_without_ctx( + ctx.garage, + req, + ctx.bucket_id, + key, + StatusCode::OK, + part_number, + overrides, + ) + .await } /// Handle GET request @@ -294,6 +312,7 @@ pub async fn handle_get_without_ctx( req: &Request, bucket_id: Uuid, key: &str, + status_code: StatusCode, part_number: Option, overrides: GetObjectOverrides, ) -> Result, Error> { @@ -329,11 +348,15 @@ pub async fn handle_get_without_ctx( let checksum_mode = checksum_mode(&req); - match (part_number, parse_range_header(req, last_v_meta.size)?) { - (Some(_), Some(_)) => Err(Error::bad_request( + match ( + part_number, + parse_range_header(req, last_v_meta.size)?, + status_code == StatusCode::OK, + ) { + (Some(_), Some(_), _) => Err(Error::bad_request( "Cannot specify both partNumber and Range header", )), - (Some(pn), None) => { + (Some(pn), None, true) => { handle_get_part( garage, last_v, @@ -346,7 +369,7 @@ pub async fn handle_get_without_ctx( ) .await } - (None, Some(range)) => { + (None, Some(range), true) => { handle_get_range( garage, last_v, @@ -360,7 +383,8 @@ pub async fn handle_get_without_ctx( ) .await } - (None, None) => { + _ => { + // either not a range, or an error request: always return the full doc handle_get_full( garage, last_v, @@ -370,6 +394,7 @@ pub async fn handle_get_without_ctx( &headers, overrides, checksum_mode, + status_code, ) .await } @@ -385,6 +410,7 @@ async fn handle_get_full( meta_inner: &ObjectVersionMetaInner, overrides: GetObjectOverrides, checksum_mode: ChecksumMode, + status_code: StatusCode, ) -> Result, Error> { let mut resp_builder = object_headers( version, @@ -394,7 +420,7 @@ async fn handle_get_full( checksum_mode, ) .header(CONTENT_LENGTH, format!("{}", version_meta.size)) - .status(StatusCode::OK); + .status(status_code); getobject_override_headers(overrides, &mut resp_builder)?; let stream = full_object_byte_stream(garage, version, version_data, encryption); diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 6af55677..934a20ff 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -10,7 +10,7 @@ use crate::s3::error::*; use crate::s3::xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; use crate::signature::verify_signed_content; -use garage_model::bucket_table::*; +use garage_model::bucket_table::{self, *}; use garage_util::data::*; pub async fn handle_get_website(ctx: ReqCtx) -> Result, Error> { @@ -25,7 +25,8 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result, Error> suffix: Value(website.index_document.to_string()), }), redirect_all_requests_to: None, - routing_rules: None, + // TODO put the correct config here + routing_rules: Vec::new(), }; let xml = to_xml_with_header(&wc)?; Ok(Response::builder() @@ -101,8 +102,12 @@ pub struct WebsiteConfiguration { pub index_document: Option, #[serde(rename = "RedirectAllRequestsTo")] pub redirect_all_requests_to: Option, - #[serde(rename = "RoutingRules")] - pub routing_rules: Option>, + #[serde( + rename = "RoutingRules", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub routing_rules: Vec, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -166,7 +171,7 @@ impl WebsiteConfiguration { if self.redirect_all_requests_to.is_some() && (self.error_document.is_some() || self.index_document.is_some() - || self.routing_rules.is_some()) + || !self.routing_rules.is_empty()) { return Err(Error::bad_request( "Bad XML: can't have RedirectAllRequestsTo and other fields", @@ -181,10 +186,15 @@ impl WebsiteConfiguration { if let Some(ref rart) = self.redirect_all_requests_to { rart.validate()?; } - if let Some(ref rrs) = self.routing_rules { - for rr in rrs { - rr.inner.validate()?; - } + for rr in &self.routing_rules { + rr.inner.validate()?; + } + if self.routing_rules.len() > 1000 { + // we will do linear scans, best to avoid overly long configuration. The + // limit was choosen arbitrarily + return Err(Error::bad_request( + "Bad XML: RoutingRules can't have more than 1000 child elements", + )); } Ok(()) @@ -195,10 +205,12 @@ impl WebsiteConfiguration { Err(Error::NotImplemented( "S3 website redirects are not currently implemented in Garage.".into(), )) - } else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) { - Err(Error::NotImplemented( - "S3 routing rules are not currently implemented in Garage.".into(), - )) + /* + } else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) { + Err(Error::NotImplemented( + "S3 routing rules are not currently implemented in Garage.".into(), + )) + */ } else { Ok(WebsiteConfig { index_document: self @@ -206,6 +218,35 @@ impl WebsiteConfiguration { .map(|x| x.suffix.0) .unwrap_or_else(|| "index.html".to_string()), error_document: self.error_document.map(|x| x.key.0), + routing_rules: self + .routing_rules + .into_iter() + .map(|rule| { + bucket_table::RoutingRule { + condition: rule.inner.condition.map(|condition| { + bucket_table::Condition { + http_error_code: condition.http_error_code.map(|c| c.0 as u16), + prefix: condition.prefix.map(|p| p.0), + } + }), + redirect: bucket_table::Redirect { + hostname: rule.inner.redirect.hostname.map(|h| h.0), + protocol: rule.inner.redirect.protocol.map(|p| p.0), + // aws default to 301, which i find punitive in case of + // missconfiguration (can be permanently cached on the + // user agent) + http_redirect_code: rule + .inner + .redirect + .http_redirect_code + .map(|c| c.0 as u16) + .unwrap_or(302), + replace_key_prefix: rule.inner.redirect.replace_prefix.map(|k| k.0), + replace_key: rule.inner.redirect.replace_full.map(|k| k.0), + }, + } + }) + .collect(), }) } } @@ -248,35 +289,69 @@ impl Target { impl RoutingRuleInner { pub fn validate(&self) -> Result<(), Error> { - let has_prefix = self - .condition - .as_ref() - .and_then(|c| c.prefix.as_ref()) - .is_some(); - self.redirect.validate(has_prefix) + if let Some(condition) = &self.condition { + condition.validate()?; + } + self.redirect.validate() + } +} + +impl Condition { + pub fn validate(&self) -> Result { + if let Some(ref error_code) = self.http_error_code { + // TODO do other error codes make sense? Aws only allows 4xx and 5xx + if error_code.0 != 404 { + return Err(Error::bad_request( + "Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent", + )); + } + } + Ok(self.prefix.is_some()) } } impl Redirect { - pub fn validate(&self, has_prefix: bool) -> Result<(), Error> { + pub fn validate(&self) -> Result<(), Error> { if self.replace_prefix.is_some() { if self.replace_full.is_some() { return Err(Error::bad_request( "Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set", )); } - if !has_prefix { - return Err(Error::bad_request( - "Bad XML: ReplaceKeyPrefixWith is set, but KeyPrefixEquals isn't", - )); - } } if let Some(ref protocol) = self.protocol { if protocol.0 != "http" && protocol.0 != "https" { return Err(Error::bad_request("Bad XML: invalid protocol")); } } - // TODO there are probably more invalide cases, but which ones? + if let Some(ref http_redirect_code) = self.http_redirect_code { + match http_redirect_code.0 { + // aws allows all 3xx except 300, but some are non-sensical (not modified, + // use proxy...) + 301 | 302 | 303 | 307 | 308 => { + if self.hostname.is_none() && self.protocol.is_some() { + return Err(Error::bad_request( + "Bad XML: HostName must be set if Protocol is set", + )); + } + } + // aws doesn't allow these codes, but netlify does, and it seems like a + // cool feature (change the page seen without changing the url shown by the + // user agent) + 200 | 404 => { + if self.hostname.is_some() || self.protocol.is_some() { + // hostname would mean different bucket, protocol doesn't make + // sense + return Err(Error::bad_request( + "Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol", + )); + } + } + _ => { + return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode")); + } + } + } Ok(()) } } @@ -330,7 +405,7 @@ mod tests { hostname: Value("garage.tld".to_owned()), protocol: Some(Value("https".to_owned())), }), - routing_rules: Some(vec![RoutingRule { + routing_rules: vec![RoutingRule { inner: RoutingRuleInner { condition: Some(Condition { http_error_code: Some(IntValue(404)), @@ -344,7 +419,7 @@ mod tests { replace_full: Some(Value("fullkey".to_owned())), }, }, - }]), + }], }; assert_eq! { ref_value, diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs index 1bdc6086..a9b4cc50 100644 --- a/src/garage/admin/bucket.rs +++ b/src/garage/admin/bucket.rs @@ -393,6 +393,7 @@ impl AdminRpcHandler { Some(WebsiteConfig { index_document: query.index_document.clone(), error_document: query.error_document.clone(), + routing_rules: Vec::new(), }) } else { None diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index 1dbdfac2..625c177d 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -60,6 +60,60 @@ mod v08 { pub struct WebsiteConfig { pub index_document: String, pub error_document: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routing_rules: Vec, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct RoutingRule { + pub condition: Option, + pub redirect: Redirect, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Condition { + pub http_error_code: Option, + pub prefix: Option, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Redirect { + pub hostname: Option, + pub http_redirect_code: u16, + pub protocol: Option, + pub replace_key_prefix: Option, + pub replace_key: Option, + } + + impl Redirect { + pub fn compute_target(&self, suffix: Option<&str>) -> String { + let mut res = String::new(); + if let Some(hostname) = &self.hostname { + if let Some(protocol) = &self.protocol { + res.push_str(&protocol); + res.push_str("://"); + } else { + res.push_str("//"); + } + res.push_str(&hostname); + } + res.push('/'); + if let Some(replace_key_prefix) = &self.replace_key_prefix { + res.push_str(&replace_key_prefix); + if let Some(suffix) = suffix { + res.push_str(suffix) + } + } else if let Some(replace_key) = &self.replace_key { + res.push_str(&replace_key) + } + res + } + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct RedirectAll { + pub hostname: String, + pub protoco: String, } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] diff --git a/src/web/web_server.rs b/src/web/web_server.rs index 69939f65..d8ce0460 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -28,6 +28,7 @@ use garage_api::s3::error::{ }; use garage_api::s3::get::{handle_get_without_ctx, handle_head_without_ctx}; +use garage_model::bucket_table::RoutingRule; use garage_model::garage::Garage; use garage_table::*; @@ -234,26 +235,32 @@ impl WebServer { // Get path let path = req.uri().path().to_string(); let index = &website_config.index_document; - let (key, may_redirect) = path_to_keys(&path, index)?; + let routing_result = path_to_keys(&path, index, &[])?; debug!( - "Selected bucket: \"{}\" {:?}, target key: \"{}\", may redirect to: {:?}", - bucket_name, bucket_id, key, may_redirect + "Selected bucket: \"{}\" {:?}, routing to {:?}", + bucket_name, bucket_id, routing_result, ); - let ret_doc = match *req.method() { - Method::OPTIONS => handle_options_for_bucket(req, &bucket_params) + let ret_doc = match (req.method(), routing_result.main_target()) { + (&Method::OPTIONS, _) => handle_options_for_bucket(req, &bucket_params) .map_err(ApiError::from) .map(|res| res.map(|_empty_body: EmptyBody| empty_body())), - Method::HEAD => { - handle_head_without_ctx(self.garage.clone(), req, bucket_id, &key, None).await + (_, Err((url, code))) => Ok(Response::builder() + .status(code) + .header("Location", url) + .body(empty_body()) + .unwrap()), + (&Method::HEAD, Ok((key, code))) => { + handle_head_without_ctx(self.garage.clone(), req, bucket_id, key, code, None).await } - Method::GET => { + (&Method::GET, Ok((key, code))) => { handle_get_without_ctx( self.garage.clone(), req, bucket_id, - &key, + key, + code, None, Default::default(), ) @@ -262,16 +269,68 @@ impl WebServer { _ => Err(ApiError::bad_request("HTTP method not supported")), }; - // Try implicit redirect on error - let ret_doc_with_redir = match (&ret_doc, may_redirect) { - (Err(ApiError::NoSuchKey), ImplicitRedirect::To { key, url }) - if self.check_key_exists(bucket_id, key.as_str()).await? => - { - Ok(Response::builder() - .status(StatusCode::FOUND) - .header("Location", url) - .body(empty_body()) - .unwrap()) + // Try handling errors if bucket configuration provided fallbacks + let ret_doc_with_redir = match (&ret_doc, &routing_result) { + ( + Err(ApiError::NoSuchKey), + RoutingResult::LoadOrRedirect { + redirect_if_exists, + redirect_url, + redirect_code, + .. + }, + ) => { + let redirect = if let Some(redirect_key) = redirect_if_exists { + self.check_key_exists(bucket_id, redirect_key.as_str()) + .await? + } else { + true + }; + if redirect { + Ok(Response::builder() + .status(redirect_code) + .header("Location", redirect_url) + .body(empty_body()) + .unwrap()) + } else { + ret_doc + } + } + ( + Err(ApiError::NoSuchKey), + RoutingResult::LoadOrAlternativeError { + redirect_key, + redirect_code, + .. + }, + ) => { + match *req.method() { + Method::HEAD => { + handle_head_without_ctx( + self.garage.clone(), + req, + bucket_id, + redirect_key, + *redirect_code, + None, + ) + .await + } + Method::GET => { + handle_get_without_ctx( + self.garage.clone(), + req, + bucket_id, + redirect_key, + *redirect_code, + None, + Default::default(), + ) + .await + } + // we shouldn't ever reach here + _ => Err(ApiError::bad_request("HTTP method not supported")), + } } _ => ret_doc, }; @@ -307,6 +366,7 @@ impl WebServer { &req2, bucket_id, &error_document, + error.http_status_code(), None, Default::default(), ) @@ -323,8 +383,6 @@ impl WebServer { error ); - *error_doc.status_mut() = error.http_status_code(); - // Preserve error message in a special header for error_line in error.to_string().split('\n') { if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) { @@ -371,9 +429,44 @@ fn error_to_res(e: Error) -> Response> { } #[derive(Debug, PartialEq)] -enum ImplicitRedirect { - No, - To { key: String, url: String }, +enum RoutingResult { + // Load a key and use `code` as status, or fallback to normal 404 handler if not found + LoadKey { + key: String, + code: StatusCode, + }, + // Load a key and use `200` as status, or fallback with a redirection using `redirect_code` + // as status + LoadOrRedirect { + key: String, + redirect_if_exists: Option, + redirect_url: String, + redirect_code: StatusCode, + }, + // Load a key and use `200` as status, or fallback by loading a different key and use + // `redirect_code` as status + LoadOrAlternativeError { + key: String, + redirect_key: String, + redirect_code: StatusCode, + }, + // Send an http redirect with `code` as status + Redirect { + url: String, + code: StatusCode, + }, +} + +impl RoutingResult { + // return Ok((key_to_deref, status_code)) or Err((redirect_target, status_code)) + fn main_target(&self) -> Result<(&str, StatusCode), (&str, StatusCode)> { + match self { + RoutingResult::LoadKey { key, code } => Ok((key, *code)), + RoutingResult::LoadOrRedirect { key, .. } => Ok((key, StatusCode::OK)), + RoutingResult::LoadOrAlternativeError { key, .. } => Ok((key, StatusCode::OK)), + RoutingResult::Redirect { url, code } => Err((url, *code)), + } + } } /// Path to key @@ -383,35 +476,128 @@ enum ImplicitRedirect { /// which is also AWS S3 behavior. /// /// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html -fn path_to_keys<'a>(path: &'a str, index: &str) -> Result<(String, ImplicitRedirect), Error> { +fn path_to_keys<'a>( + path: &'a str, + index: &str, + routing_rules: &[RoutingRule], +) -> Result { let path_utf8 = percent_encoding::percent_decode_str(path).decode_utf8()?; let base_key = match path_utf8.strip_prefix("/") { Some(bk) => bk, None => return Err(Error::BadRequest("Path must start with a / (slash)".into())), }; + let is_bucket_root = base_key.len() == 0; let is_trailing_slash = path_utf8.ends_with("/"); - match (is_bucket_root, is_trailing_slash) { - // It is not possible to store something at the root of the bucket (ie. empty key), - // the only option is to fetch the index - (true, _) => Ok((index.to_string(), ImplicitRedirect::No)), + let key = if is_bucket_root || is_trailing_slash { + // we can't store anything at the root, so we need to query the index + // if the key end with a slash, we always query the index + format!("{base_key}{index}") + } else { + // if the key doesn't end with `/`, leave it unmodified + base_key.to_string() + }; - // "If you create a folder structure in your bucket, you must have an index document at each level. In each folder, the index document must have the same name, for example, index.html. When a user specifies a URL that resembles a folder lookup, the presence or absence of a trailing slash determines the behavior of the website. For example, the following URL, with a trailing slash, returns the photos/index.html index document." - (false, true) => Ok((format!("{base_key}{index}"), ImplicitRedirect::No)), + let mut routing_rules_iter = routing_rules.iter(); + let key = loop { + let Some(routing_rule) = routing_rules_iter.next() else { + break key; + }; - // "However, if you exclude the trailing slash from the preceding URL, Amazon S3 first looks for an object photos in the bucket. If the photos object is not found, it searches for an index document, photos/index.html. If that document is found, Amazon S3 returns a 302 Found message and points to the photos/ key. For subsequent requests to photos/, Amazon S3 returns photos/index.html. If the index document is not found, Amazon S3 returns an error." - (false, false) => Ok(( - base_key.to_string(), - ImplicitRedirect::To { - key: format!("{base_key}/{index}"), - url: format!("{path}/"), - }, - )), + let Ok(status_code) = StatusCode::from_u16(routing_rule.redirect.http_redirect_code) else { + continue; + }; + if let Some(condition) = &routing_rule.condition { + let suffix = if let Some(prefix) = &condition.prefix { + let Some(suffix) = key.strip_prefix(prefix) else { + continue; + }; + Some(suffix) + } else { + None + }; + let target = routing_rule.redirect.compute_target(suffix); + let query_alternative_key = + status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND; + let redirect_on_error = + condition.http_error_code == Some(StatusCode::NOT_FOUND.as_u16()); + match (query_alternative_key, redirect_on_error) { + (false, false) => { + return Ok(RoutingResult::Redirect { + url: target, + code: status_code, + }) + } + (true, false) => { + if status_code == StatusCode::OK { + break target; + } else { + return Ok(RoutingResult::LoadKey { + key: target, + code: status_code, + }); + } + } + (false, true) => { + return Ok(RoutingResult::LoadOrRedirect { + key, + redirect_if_exists: None, + redirect_url: target, + redirect_code: status_code, + }); + } + (true, true) => { + return Ok(RoutingResult::LoadOrAlternativeError { + key, + redirect_key: target, + redirect_code: status_code, + }); + } + } + } else { + let target = routing_rule.redirect.compute_target(None); + return Ok(RoutingResult::Redirect { + url: target, + code: status_code, + }); + } + }; + + if is_bucket_root || is_trailing_slash { + Ok(RoutingResult::LoadKey { + key, + code: StatusCode::OK, + }) + } else { + Ok(RoutingResult::LoadOrRedirect { + redirect_if_exists: Some(format!("{key}/{index}")), + key, + // we can't use `path` because key might have changed substentially in case of + // routing rules + redirect_url: percent_encoding::percent_encode( + format!("{path}/").as_bytes(), + PATH_ENCODING_SET, + ) + .to_string(), + redirect_code: StatusCode::FOUND, + }) } } +// per https://url.spec.whatwg.org/#path-percent-encode-set +const PATH_ENCODING_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + #[cfg(test)] mod tests { use super::*; @@ -419,35 +605,39 @@ mod tests { #[test] fn path_to_keys_test() -> Result<(), Error> { assert_eq!( - path_to_keys("/file%20.jpg", "index.html")?, - ( - "file .jpg".to_string(), - ImplicitRedirect::To { - key: "file .jpg/index.html".to_string(), - url: "/file%20.jpg/".to_string() - } - ) + path_to_keys("/file%20.jpg", "index.html", &[])?, + RoutingResult::LoadOrRedirect { + key: "file .jpg".to_string(), + redirect_url: "/file%20.jpg/".to_string(), + redirect_if_exists: Some("file .jpg/index.html".to_string()), + redirect_code: StatusCode::FOUND, + } ); assert_eq!( - path_to_keys("/%20t/", "index.html")?, - (" t/index.html".to_string(), ImplicitRedirect::No) + path_to_keys("/%20t/", "index.html", &[])?, + RoutingResult::LoadKey { + key: " t/index.html".to_string(), + code: StatusCode::OK + } ); assert_eq!( - path_to_keys("/", "index.html")?, - ("index.html".to_string(), ImplicitRedirect::No) + path_to_keys("/", "index.html", &[])?, + RoutingResult::LoadKey { + key: "index.html".to_string(), + code: StatusCode::OK + } ); assert_eq!( - path_to_keys("/hello", "index.html")?, - ( - "hello".to_string(), - ImplicitRedirect::To { - key: "hello/index.html".to_string(), - url: "/hello/".to_string() - } - ) + path_to_keys("/hello", "index.html", &[])?, + RoutingResult::LoadOrRedirect { + key: "hello".to_string(), + redirect_url: "/hello/".to_string(), + redirect_if_exists: Some("hello/index.html".to_string()), + redirect_code: StatusCode::FOUND, + } ); - assert!(path_to_keys("", "index.html").is_err()); - assert!(path_to_keys("i/am/relative", "index.html").is_err()); + assert!(path_to_keys("", "index.html", &[]).is_err()); + assert!(path_to_keys("i/am/relative", "index.html", &[]).is_err()); Ok(()) } } From 65e9dde8c99a4c1406e58da0741c9e566d18b1ff Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sun, 22 Dec 2024 15:20:09 +0100 Subject: [PATCH 003/192] add tests --- src/api/s3/website.rs | 130 ++++++---- src/garage/tests/s3/website.rs | 446 ++++++++++++++++++++++++++++++++- src/web/web_server.rs | 11 +- 3 files changed, 538 insertions(+), 49 deletions(-) diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 934a20ff..ad592260 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -25,8 +25,28 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result, Error> suffix: Value(website.index_document.to_string()), }), redirect_all_requests_to: None, - // TODO put the correct config here - routing_rules: Vec::new(), + routing_rules: RoutingRules { + rules: website + .routing_rules + .clone() + .into_iter() + .map(|rule| RoutingRule { + condition: rule.condition.map(|cond| Condition { + http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)), + prefix: cond.prefix.map(Value), + }), + redirect: Redirect { + hostname: rule.redirect.hostname.map(Value), + http_redirect_code: Some(IntValue( + rule.redirect.http_redirect_code as i64, + )), + protocol: rule.redirect.protocol.map(Value), + replace_full: rule.redirect.replace_key.map(Value), + replace_prefix: rule.redirect.replace_key_prefix.map(Value), + }, + }) + .collect(), + }, }; let xml = to_xml_with_header(&wc)?; Ok(Response::builder() @@ -105,19 +125,25 @@ pub struct WebsiteConfiguration { #[serde( rename = "RoutingRules", default, - skip_serializing_if = "Vec::is_empty" + skip_serializing_if = "RoutingRules::is_empty" )] - pub routing_rules: Vec, + pub routing_rules: RoutingRules, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct RoutingRules { + #[serde(rename = "RoutingRule")] + pub rules: Vec, +} + +impl RoutingRules { + fn is_empty(&self) -> bool { + self.rules.is_empty() + } } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct RoutingRule { - #[serde(rename = "RoutingRule")] - pub inner: RoutingRuleInner, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct RoutingRuleInner { #[serde(rename = "Condition")] pub condition: Option, #[serde(rename = "Redirect")] @@ -186,10 +212,10 @@ impl WebsiteConfiguration { if let Some(ref rart) = self.redirect_all_requests_to { rart.validate()?; } - for rr in &self.routing_rules { - rr.inner.validate()?; + for rr in &self.routing_rules.rules { + rr.validate()?; } - if self.routing_rules.len() > 1000 { + if self.routing_rules.rules.len() > 1000 { // we will do linear scans, best to avoid overly long configuration. The // limit was choosen arbitrarily return Err(Error::bad_request( @@ -205,12 +231,6 @@ impl WebsiteConfiguration { Err(Error::NotImplemented( "S3 website redirects are not currently implemented in Garage.".into(), )) - /* - } else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) { - Err(Error::NotImplemented( - "S3 routing rules are not currently implemented in Garage.".into(), - )) - */ } else { Ok(WebsiteConfig { index_document: self @@ -220,29 +240,27 @@ impl WebsiteConfiguration { error_document: self.error_document.map(|x| x.key.0), routing_rules: self .routing_rules + .rules .into_iter() .map(|rule| { bucket_table::RoutingRule { - condition: rule.inner.condition.map(|condition| { - bucket_table::Condition { - http_error_code: condition.http_error_code.map(|c| c.0 as u16), - prefix: condition.prefix.map(|p| p.0), - } + condition: rule.condition.map(|condition| bucket_table::Condition { + http_error_code: condition.http_error_code.map(|c| c.0 as u16), + prefix: condition.prefix.map(|p| p.0), }), redirect: bucket_table::Redirect { - hostname: rule.inner.redirect.hostname.map(|h| h.0), - protocol: rule.inner.redirect.protocol.map(|p| p.0), + hostname: rule.redirect.hostname.map(|h| h.0), + protocol: rule.redirect.protocol.map(|p| p.0), // aws default to 301, which i find punitive in case of // missconfiguration (can be permanently cached on the // user agent) http_redirect_code: rule - .inner .redirect .http_redirect_code .map(|c| c.0 as u16) .unwrap_or(302), - replace_key_prefix: rule.inner.redirect.replace_prefix.map(|k| k.0), - replace_key: rule.inner.redirect.replace_full.map(|k| k.0), + replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0), + replace_key: rule.redirect.replace_full.map(|k| k.0), }, } }) @@ -287,7 +305,7 @@ impl Target { } } -impl RoutingRuleInner { +impl RoutingRule { pub fn validate(&self) -> Result<(), Error> { if let Some(condition) = &self.condition { condition.validate()?; @@ -390,6 +408,15 @@ mod tests { fullkey + + + + + + 404 + missing + + "#; let conf: WebsiteConfiguration = from_str(message).unwrap(); @@ -405,21 +432,36 @@ mod tests { hostname: Value("garage.tld".to_owned()), protocol: Some(Value("https".to_owned())), }), - routing_rules: vec![RoutingRule { - inner: RoutingRuleInner { - condition: Some(Condition { - http_error_code: Some(IntValue(404)), - prefix: Some(Value("prefix1".to_owned())), - }), - redirect: Redirect { - hostname: Some(Value("gara.ge".to_owned())), - protocol: Some(Value("http".to_owned())), - http_redirect_code: Some(IntValue(303)), - replace_prefix: Some(Value("prefix2".to_owned())), - replace_full: Some(Value("fullkey".to_owned())), + routing_rules: RoutingRules { + rules: vec![ + RoutingRule { + condition: Some(Condition { + http_error_code: Some(IntValue(404)), + prefix: Some(Value("prefix1".to_owned())), + }), + redirect: Redirect { + hostname: Some(Value("gara.ge".to_owned())), + protocol: Some(Value("http".to_owned())), + http_redirect_code: Some(IntValue(303)), + replace_prefix: Some(Value("prefix2".to_owned())), + replace_full: Some(Value("fullkey".to_owned())), + }, }, - }, - }], + RoutingRule { + condition: Some(Condition { + http_error_code: None, + prefix: Some(Value("".to_owned())), + }), + redirect: Redirect { + hostname: None, + protocol: None, + http_redirect_code: Some(IntValue(404)), + replace_prefix: None, + replace_full: Some(Value("missing".to_owned())), + }, + }, + ], + }, }; assert_eq! { ref_value, diff --git a/src/garage/tests/s3/website.rs b/src/garage/tests/s3/website.rs index 0cadc388..12d4973b 100644 --- a/src/garage/tests/s3/website.rs +++ b/src/garage/tests/s3/website.rs @@ -5,7 +5,10 @@ use crate::json_body; use assert_json_diff::assert_json_eq; use aws_sdk_s3::{ primitives::ByteStream, - types::{CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, WebsiteConfiguration}, + types::{ + Condition, CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, Protocol, Redirect, + RoutingRule, WebsiteConfiguration, + }, }; use http::{Request, StatusCode}; use http_body_util::BodyExt; @@ -505,3 +508,444 @@ async fn test_website_check_domain() { }) ); } + +#[tokio::test] +async fn test_website_redirect_full_bucket() { + const BCKT_NAME: &str = "my-redirect-full"; + let ctx = common::context(); + let bucket = ctx.create_bucket(BCKT_NAME); + + let conf = WebsiteConfiguration::builder() + .routing_rules( + RoutingRule::builder() + .condition(Condition::builder().key_prefix_equals("").build()) + .redirect( + Redirect::builder() + .protocol(Protocol::Https) + .host_name("other.tld") + .replace_key_prefix_with("") + .build(), + ) + .build(), + ) + .build(); + + ctx.client + .put_bucket_website() + .bucket(&bucket) + .website_configuration(conf) + .send() + .await + .unwrap(); + + let req = Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/my-path", ctx.garage.web_port)) + .header("Host", format!("{}.web.garage", BCKT_NAME)) + .body(Body::new(Bytes::new())) + .unwrap(); + + let client = Client::builder(TokioExecutor::new()).build_http(); + let resp = client.request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!( + resp.headers() + .get(hyper::header::LOCATION) + .unwrap() + .to_str() + .unwrap(), + "https://other.tld/my-path" + ); +} + +#[tokio::test] +async fn test_website_redirect() { + const BCKT_NAME: &str = "my-redirect"; + let ctx = common::context(); + let bucket = ctx.create_bucket(BCKT_NAME); + + ctx.client + .put_object() + .bucket(&bucket) + .key("index.html") + .body(ByteStream::from_static(b"index")) + .send() + .await + .unwrap(); + ctx.client + .put_object() + .bucket(&bucket) + .key("404.html") + .body(ByteStream::from_static(b"main 404")) + .send() + .await + .unwrap(); + ctx.client + .put_object() + .bucket(&bucket) + .key("static-file") + .body(ByteStream::from_static(b"static file")) + .send() + .await + .unwrap(); + + let mut conf = WebsiteConfiguration::builder() + .index_document( + IndexDocument::builder() + .suffix("home.html") + .build() + .unwrap(), + ) + .error_document(ErrorDocument::builder().key("404.html").build().unwrap()); + + for (prefix, condition) in [("unconditional", false), ("conditional", true)] { + let code = condition.then(|| "404".to_string()); + conf = conf + // simple redirect + .routing_rules( + RoutingRule::builder() + .condition( + Condition::builder() + .set_http_error_code_returned_equals(code.clone()) + .key_prefix_equals(format!("{prefix}/redirect-prefix/")) + .build(), + ) + .redirect( + Redirect::builder() + .http_redirect_code("302") + .replace_key_prefix_with("other-prefix/") + .build(), + ) + .build(), + ) + .routing_rules( + RoutingRule::builder() + .condition( + Condition::builder() + .set_http_error_code_returned_equals(code.clone()) + .key_prefix_equals(format!("{prefix}/redirect-prefix-307/")) + .build(), + ) + .redirect( + Redirect::builder() + .http_redirect_code("307") + .replace_key_prefix_with("other-prefix/") + .build(), + ) + .build(), + ) + // simple redirect + .routing_rules( + RoutingRule::builder() + .condition( + Condition::builder() + .set_http_error_code_returned_equals(code.clone()) + .key_prefix_equals(format!("{prefix}/redirect-fixed/")) + .build(), + ) + .redirect( + Redirect::builder() + .http_redirect_code("302") + .replace_key_with("fixed_key") + .build(), + ) + .build(), + ) + // stream other file + .routing_rules( + RoutingRule::builder() + .condition( + Condition::builder() + .set_http_error_code_returned_equals(code.clone()) + .key_prefix_equals(format!("{prefix}/stream-fixed/")) + .build(), + ) + .redirect( + Redirect::builder() + .http_redirect_code("200") + .replace_key_with("static-file") + .build(), + ) + .build(), + ) + // stream other file as error + .routing_rules( + RoutingRule::builder() + .condition( + Condition::builder() + .set_http_error_code_returned_equals(code.clone()) + .key_prefix_equals(format!("{prefix}/stream-404/")) + .build(), + ) + .redirect( + Redirect::builder() + .http_redirect_code("404") + .replace_key_with("static-file") + .build(), + ) + .build(), + ) + // fail to stream other file + .routing_rules( + RoutingRule::builder() + .condition( + Condition::builder() + .set_http_error_code_returned_equals(code.clone()) + .key_prefix_equals(format!("{prefix}/stream-missing/")) + .build(), + ) + .redirect( + Redirect::builder() + .http_redirect_code("200") + .replace_key_with("missing-file") + .build(), + ) + .build(), + ); + } + let conf = conf.build(); + + ctx.client + .put_bucket_website() + .bucket(&bucket) + .website_configuration(conf.clone()) + .send() + .await + .unwrap(); + + let stored_cfg = ctx + .client + .get_bucket_website() + .bucket(&bucket) + .send() + .await + .unwrap(); + assert_eq!(stored_cfg.index_document, conf.index_document); + assert_eq!(stored_cfg.error_document, conf.error_document); + assert_eq!(stored_cfg.routing_rules, conf.routing_rules); + + let req = |path| { + Request::builder() + .method("GET") + .uri(format!( + "http://127.0.0.1:{}/{}/path", + ctx.garage.web_port, path + )) + .header("Host", format!("{}.web.garage", BCKT_NAME)) + .body(Body::new(Bytes::new())) + .unwrap() + }; + + test_redirect_helper("unconditional", true, &req).await; + test_redirect_helper("conditional", true, &req).await; + for prefix in ["unconditional", "conditional"] { + for rule_path in [ + "redirect-prefix", + "redirect-prefix-307", + "redirect-fixed", + "stream-fixed", + "stream-404", + "stream-missing", + ] { + ctx.client + .put_object() + .bucket(&bucket) + .key(format!("{prefix}/{rule_path}/path")) + .body(ByteStream::from_static(b"i exist")) + .send() + .await + .unwrap(); + } + } + test_redirect_helper("unconditional", true, &req).await; + test_redirect_helper("conditional", false, &req).await; +} + +async fn test_redirect_helper( + prefix: &str, + should_see_redirect: bool, + req: impl Fn(String) -> Request>, +) { + use http::header; + let client = Client::builder(TokioExecutor::new()).build_http(); + let expected_body = b"i exist".as_ref(); + + let resp = client + .request(req(format!("{prefix}/redirect-prefix"))) + .await + .unwrap(); + if should_see_redirect { + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!( + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), + "/other-prefix/path" + ); + assert!(resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); + } else { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + expected_body, + ); + } + + let resp = client + .request(req(format!("{prefix}/redirect-prefix-307"))) + .await + .unwrap(); + if should_see_redirect { + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), + "/other-prefix/path" + ); + assert!(resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); + } else { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + expected_body, + ); + } + + let resp = client + .request(req(format!("{prefix}/redirect-fixed"))) + .await + .unwrap(); + if should_see_redirect { + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!( + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), + "/fixed_key" + ); + assert!(resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); + } else { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + expected_body, + ); + } + let resp = client + .request(req(format!("{prefix}/stream-fixed"))) + .await + .unwrap(); + if should_see_redirect { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + b"static file".as_ref(), + ); + } else { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + expected_body, + ); + } + let resp = client + .request(req(format!("{prefix}/stream-404"))) + .await + .unwrap(); + if should_see_redirect { + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + b"static file".as_ref(), + ); + } else { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + expected_body, + ); + } + let resp = client + .request(req(format!("{prefix}/stream-404"))) + .await + .unwrap(); + if should_see_redirect { + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + b"static file".as_ref(), + ); + } else { + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(header::LOCATION).is_none()); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + expected_body, + ); + } +} + +#[tokio::test] +async fn test_website_invalid_redirect() { + const BCKT_NAME: &str = "my-invalid-redirect"; + let ctx = common::context(); + let bucket = ctx.create_bucket(BCKT_NAME); + + let conf = WebsiteConfiguration::builder() + .routing_rules( + RoutingRule::builder() + .condition(Condition::builder().key_prefix_equals("").build()) + .redirect( + Redirect::builder() + .protocol(Protocol::Https) + .host_name("other.tld") + .replace_key_prefix_with("") + // we don't allow 200 with hostname + .http_redirect_code("200") + .build(), + ) + .build(), + ) + .build(); + + ctx.client + .put_bucket_website() + .bucket(&bucket) + .website_configuration(conf) + .send() + .await + .unwrap_err(); +} diff --git a/src/web/web_server.rs b/src/web/web_server.rs index d8ce0460..dfdbf8a2 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -235,7 +235,7 @@ impl WebServer { // Get path let path = req.uri().path().to_string(); let index = &website_config.index_document; - let routing_result = path_to_keys(&path, index, &[])?; + let routing_result = path_to_keys(&path, index, &website_config.routing_rules)?; debug!( "Selected bucket: \"{}\" {:?}, routing to {:?}", @@ -518,7 +518,7 @@ fn path_to_keys<'a>( } else { None }; - let target = routing_rule.redirect.compute_target(suffix); + let mut target = routing_rule.redirect.compute_target(suffix); let query_alternative_key = status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND; let redirect_on_error = @@ -531,6 +531,8 @@ fn path_to_keys<'a>( }) } (true, false) => { + // we need to remove the leading / + target.remove(0); if status_code == StatusCode::OK { break target; } else { @@ -549,6 +551,7 @@ fn path_to_keys<'a>( }); } (true, true) => { + target.remove(0); return Ok(RoutingResult::LoadOrAlternativeError { key, redirect_key: target, @@ -573,14 +576,14 @@ fn path_to_keys<'a>( } else { Ok(RoutingResult::LoadOrRedirect { redirect_if_exists: Some(format!("{key}/{index}")), - key, // we can't use `path` because key might have changed substentially in case of // routing rules redirect_url: percent_encoding::percent_encode( - format!("{path}/").as_bytes(), + format!("/{key}/").as_bytes(), PATH_ENCODING_SET, ) .to_string(), + key, redirect_code: StatusCode::FOUND, }) } From c939d2a936e60e8cb67ecae7a35706e7b7d73ab6 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sun, 22 Dec 2024 15:26:06 +0100 Subject: [PATCH 004/192] clippy --- src/api/s3/website.rs | 10 ++++------ src/model/bucket_table.rs | 8 ++++---- src/web/web_server.rs | 6 +++--- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index ad592260..6ec786a0 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -330,12 +330,10 @@ impl Condition { impl Redirect { pub fn validate(&self) -> Result<(), Error> { - if self.replace_prefix.is_some() { - if self.replace_full.is_some() { - return Err(Error::bad_request( - "Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set", - )); - } + if self.replace_prefix.is_some() && self.replace_full.is_some() { + return Err(Error::bad_request( + "Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set", + )); } if let Some(ref protocol) = self.protocol { if protocol.0 != "http" && protocol.0 != "https" { diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index 625c177d..644189b2 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -90,21 +90,21 @@ mod v08 { let mut res = String::new(); if let Some(hostname) = &self.hostname { if let Some(protocol) = &self.protocol { - res.push_str(&protocol); + res.push_str(protocol); res.push_str("://"); } else { res.push_str("//"); } - res.push_str(&hostname); + res.push_str(hostname); } res.push('/'); if let Some(replace_key_prefix) = &self.replace_key_prefix { - res.push_str(&replace_key_prefix); + res.push_str(replace_key_prefix); if let Some(suffix) = suffix { res.push_str(suffix) } } else if let Some(replace_key) = &self.replace_key { - res.push_str(&replace_key) + res.push_str(replace_key) } res } diff --git a/src/web/web_server.rs b/src/web/web_server.rs index dfdbf8a2..c497270a 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -476,8 +476,8 @@ impl RoutingResult { /// which is also AWS S3 behavior. /// /// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html -fn path_to_keys<'a>( - path: &'a str, +fn path_to_keys( + path: &str, index: &str, routing_rules: &[RoutingRule], ) -> Result { @@ -488,7 +488,7 @@ fn path_to_keys<'a>( None => return Err(Error::BadRequest("Path must start with a / (slash)".into())), }; - let is_bucket_root = base_key.len() == 0; + let is_bucket_root = base_key.is_empty(); let is_trailing_slash = path_utf8.ends_with("/"); let key = if is_bucket_root || is_trailing_slash { From 6ccfbb298631e0176d587fe17f736dd2100b38b2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 17:04:17 +0100 Subject: [PATCH 005/192] remove obsolete RedirectAll struct --- src/model/bucket_table.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index 644189b2..eba89e9b 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -110,12 +110,6 @@ mod v08 { } } - #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] - pub struct RedirectAll { - pub hostname: String, - pub protoco: String, - } - #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub struct CorsRule { pub id: Option, From 22487ceddfd3b8d0641601874e0f19143da38699 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 18:22:42 +0100 Subject: [PATCH 006/192] move Redirect::compute_target to standalone function in web_server.rs --- src/model/bucket_table.rs | 25 ------------------------- src/web/web_server.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index eba89e9b..b6daab75 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -85,31 +85,6 @@ mod v08 { pub replace_key: Option, } - impl Redirect { - pub fn compute_target(&self, suffix: Option<&str>) -> String { - let mut res = String::new(); - if let Some(hostname) = &self.hostname { - if let Some(protocol) = &self.protocol { - res.push_str(protocol); - res.push_str("://"); - } else { - res.push_str("//"); - } - res.push_str(hostname); - } - res.push('/'); - if let Some(replace_key_prefix) = &self.replace_key_prefix { - res.push_str(replace_key_prefix); - if let Some(suffix) = suffix { - res.push_str(suffix) - } - } else if let Some(replace_key) = &self.replace_key { - res.push_str(replace_key) - } - res - } - } - #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub struct CorsRule { pub id: Option, diff --git a/src/web/web_server.rs b/src/web/web_server.rs index c497270a..bd543bb0 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -28,7 +28,7 @@ use garage_api::s3::error::{ }; use garage_api::s3::get::{handle_get_without_ctx, handle_head_without_ctx}; -use garage_model::bucket_table::RoutingRule; +use garage_model::bucket_table::{self, RoutingRule}; use garage_model::garage::Garage; use garage_table::*; @@ -518,7 +518,7 @@ fn path_to_keys( } else { None }; - let mut target = routing_rule.redirect.compute_target(suffix); + let mut target = compute_redirect_target(&routing_rule.redirect, suffix); let query_alternative_key = status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND; let redirect_on_error = @@ -560,7 +560,7 @@ fn path_to_keys( } } } else { - let target = routing_rule.redirect.compute_target(None); + let target = compute_redirect_target(&routing_rule.redirect, None); return Ok(RoutingResult::Redirect { url: target, code: status_code, @@ -601,6 +601,29 @@ const PATH_ENCODING_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTRO .add(b'{') .add(b'}'); +fn compute_redirect_target(redirect: &bucket_table::Redirect, suffix: Option<&str>) -> String { + let mut res = String::new(); + if let Some(hostname) = &redirect.hostname { + if let Some(protocol) = &redirect.protocol { + res.push_str(protocol); + res.push_str("://"); + } else { + res.push_str("//"); + } + res.push_str(hostname); + } + res.push('/'); + if let Some(replace_key_prefix) = &redirect.replace_key_prefix { + res.push_str(replace_key_prefix); + if let Some(suffix) = suffix { + res.push_str(suffix) + } + } else if let Some(replace_key) = &redirect.replace_key { + res.push_str(replace_key) + } + res +} + #[cfg(test)] mod tests { use super::*; From 44ce6ae5b4c5b30a144d37aab1d11e7237a954c1 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 18:50:49 +0100 Subject: [PATCH 007/192] properly implement new bucket model using a migration --- src/model/bucket_table.rs | 130 ++++++++++++++++++++++++++++++------- src/util/crdt/deletable.rs | 10 +++ src/util/crdt/lww.rs | 10 +++ 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index b6daab75..b2679323 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -60,29 +60,6 @@ mod v08 { pub struct WebsiteConfig { pub index_document: String, pub error_document: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routing_rules: Vec, - } - - #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] - pub struct RoutingRule { - pub condition: Option, - pub redirect: Redirect, - } - - #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] - pub struct Condition { - pub http_error_code: Option, - pub prefix: Option, - } - - #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] - pub struct Redirect { - pub hostname: Option, - pub http_redirect_code: u16, - pub protocol: Option, - pub replace_key_prefix: Option, - pub replace_key: Option, } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] @@ -142,7 +119,112 @@ mod v08 { impl garage_util::migrate::InitialFormat for Bucket {} } -pub use v08::*; +mod v2 { + use crate::permission::BucketKeyPerm; + use garage_util::crdt; + use garage_util::data::Uuid; + use serde::{Deserialize, Serialize}; + + use super::v08; + + pub use v08::{BucketQuotas, CorsRule, LifecycleExpiration, LifecycleFilter, LifecycleRule}; + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Bucket { + /// ID of the bucket + pub id: Uuid, + /// State, and configuration if not deleted, of the bucket + pub state: crdt::Deletable, + } + + /// Configuration for a bucket + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct BucketParams { + /// Bucket's creation date + pub creation_date: u64, + /// Map of key with access to the bucket, and what kind of access they give + pub authorized_keys: crdt::Map, + + /// Map of aliases that are or have been given to this bucket + /// in the global namespace + /// (not authoritative: this is just used as an indication to + /// map back to aliases when doing ListBuckets) + pub aliases: crdt::LwwMap, + /// Map of aliases that are or have been given to this bucket + /// in namespaces local to keys + /// key = (access key id, alias name) + pub local_aliases: crdt::LwwMap<(String, String), bool>, + + /// Whether this bucket is allowed for website access + /// (under all of its global alias names), + /// and if so, the website configuration XML document + pub website_config: crdt::Lww>, + /// CORS rules + pub cors_config: crdt::Lww>>, + /// Lifecycle configuration + pub lifecycle_config: crdt::Lww>>, + /// Bucket quotas + pub quotas: crdt::Lww, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct WebsiteConfig { + pub index_document: String, + pub error_document: Option, + pub routing_rules: Vec, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct RoutingRule { + pub condition: Option, + pub redirect: Redirect, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Condition { + pub http_error_code: Option, + pub prefix: Option, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Redirect { + pub hostname: Option, + pub http_redirect_code: u16, + pub protocol: Option, + pub replace_key_prefix: Option, + pub replace_key: Option, + } + + impl garage_util::migrate::Migrate for Bucket { + const VERSION_MARKER: &'static [u8] = b"G2bkt"; + + type Previous = v08::Bucket; + + fn migrate(old: v08::Bucket) -> Bucket { + Bucket { + id: old.id, + state: old.state.map(|x| BucketParams { + creation_date: x.creation_date, + authorized_keys: x.authorized_keys, + aliases: x.aliases, + local_aliases: x.local_aliases, + website_config: x.website_config.map(|wc_opt| { + wc_opt.map(|wc| WebsiteConfig { + index_document: wc.index_document, + error_document: wc.error_document, + routing_rules: vec![], + }) + }), + cors_config: x.cors_config, + lifecycle_config: x.lifecycle_config, + quotas: x.quotas, + }), + } + } + } +} + +pub use v2::*; impl AutoCrdt for BucketQuotas { const WARN_IF_DIFFERENT: bool = true; diff --git a/src/util/crdt/deletable.rs b/src/util/crdt/deletable.rs index e771aceb..0594d850 100644 --- a/src/util/crdt/deletable.rs +++ b/src/util/crdt/deletable.rs @@ -9,6 +9,16 @@ pub enum Deletable { Deleted, } +impl Deletable { + /// Map value, used for migrations + pub fn map U>(self, f: F) -> Deletable { + match self { + Self::Present(x) => Deletable::::Present(f(x)), + Self::Deleted => Deletable::::Deleted, + } + } +} + impl Deletable { /// Create a new deletable object that isn't deleted pub fn present(v: T) -> Self { diff --git a/src/util/crdt/lww.rs b/src/util/crdt/lww.rs index 958844c9..2e5875ea 100644 --- a/src/util/crdt/lww.rs +++ b/src/util/crdt/lww.rs @@ -43,6 +43,16 @@ pub struct Lww { v: T, } +impl Lww { + /// Map value, used for migrations + pub fn map U>(self, f: F) -> Lww { + Lww:: { + ts: self.ts, + v: f(self.v), + } + } +} + impl Lww where T: Crdt, From 9b7fea4cb0acabee5e6a4272e00e4947090630d4 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 19:16:24 +0100 Subject: [PATCH 008/192] put bucket website: improve error message for redirectallrequests --- src/api/s3/website.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 6ec786a0..9c1422b5 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -229,7 +229,7 @@ impl WebsiteConfiguration { pub fn into_garage_website_config(self) -> Result { if self.redirect_all_requests_to.is_some() { Err(Error::NotImplemented( - "S3 website redirects are not currently implemented in Garage.".into(), + "RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single inconditional RoutingRule.".into(), )) } else { Ok(WebsiteConfig { From 47467df83e7be107d92ec9fefb23542f760e9039 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 19:50:57 +0100 Subject: [PATCH 009/192] avoid handling status_code-related logic in api/s3/get.rs --- src/api/s3/get.rs | 44 +++++------------------ src/web/web_server.rs | 83 +++++++++++++++++++++++++++++++++---------- 2 files changed, 73 insertions(+), 54 deletions(-) diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index eea3434e..f5d3cf11 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -163,15 +163,7 @@ pub async fn handle_head( key: &str, part_number: Option, ) -> Result, Error> { - handle_head_without_ctx( - ctx.garage, - req, - ctx.bucket_id, - key, - StatusCode::OK, - part_number, - ) - .await + handle_head_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number).await } /// Handle HEAD request for website @@ -180,7 +172,6 @@ pub async fn handle_head_without_ctx( req: &Request, bucket_id: Uuid, key: &str, - status_code: StatusCode, part_number: Option, ) -> Result, Error> { let object = garage @@ -281,7 +272,7 @@ pub async fn handle_head_without_ctx( checksum_mode, ) .header(CONTENT_LENGTH, format!("{}", version_meta.size)) - .status(status_code) + .status(StatusCode::OK) .body(empty_body())?) } } @@ -294,16 +285,7 @@ pub async fn handle_get( part_number: Option, overrides: GetObjectOverrides, ) -> Result, Error> { - handle_get_without_ctx( - ctx.garage, - req, - ctx.bucket_id, - key, - StatusCode::OK, - part_number, - overrides, - ) - .await + handle_get_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number, overrides).await } /// Handle GET request @@ -312,7 +294,6 @@ pub async fn handle_get_without_ctx( req: &Request, bucket_id: Uuid, key: &str, - status_code: StatusCode, part_number: Option, overrides: GetObjectOverrides, ) -> Result, Error> { @@ -348,15 +329,11 @@ pub async fn handle_get_without_ctx( let checksum_mode = checksum_mode(&req); - match ( - part_number, - parse_range_header(req, last_v_meta.size)?, - status_code == StatusCode::OK, - ) { - (Some(_), Some(_), _) => Err(Error::bad_request( + match (part_number, parse_range_header(req, last_v_meta.size)?) { + (Some(_), Some(_)) => Err(Error::bad_request( "Cannot specify both partNumber and Range header", )), - (Some(pn), None, true) => { + (Some(pn), None) => { handle_get_part( garage, last_v, @@ -369,7 +346,7 @@ pub async fn handle_get_without_ctx( ) .await } - (None, Some(range), true) => { + (None, Some(range)) => { handle_get_range( garage, last_v, @@ -383,8 +360,7 @@ pub async fn handle_get_without_ctx( ) .await } - _ => { - // either not a range, or an error request: always return the full doc + (None, None) => { handle_get_full( garage, last_v, @@ -394,7 +370,6 @@ pub async fn handle_get_without_ctx( &headers, overrides, checksum_mode, - status_code, ) .await } @@ -410,7 +385,6 @@ async fn handle_get_full( meta_inner: &ObjectVersionMetaInner, overrides: GetObjectOverrides, checksum_mode: ChecksumMode, - status_code: StatusCode, ) -> Result, Error> { let mut resp_builder = object_headers( version, @@ -420,7 +394,7 @@ async fn handle_get_full( checksum_mode, ) .header(CONTENT_LENGTH, format!("{}", version_meta.size)) - .status(status_code); + .status(StatusCode::OK); getobject_override_headers(overrides, &mut resp_builder)?; let stream = full_object_byte_stream(garage, version, version_data, encryption); diff --git a/src/web/web_server.rs b/src/web/web_server.rs index bd543bb0..23a21614 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -6,6 +6,7 @@ use tokio::net::{TcpListener, UnixListener}; use tokio::sync::watch; use hyper::{ + body::Body, body::Incoming as IncomingBody, header::{HeaderValue, HOST}, Method, Request, Response, StatusCode, @@ -22,6 +23,7 @@ use crate::error::*; use garage_api::generic_server::{server_loop, UnixListenerOn}; use garage_api::helpers::*; +use garage_api::s3::api_server::ResBody; use garage_api::s3::cors::{add_cors_headers, find_matching_cors_rule, handle_options_for_bucket}; use garage_api::s3::error::{ CommonErrorDerivative, Error as ApiError, OkOrBadRequest, OkOrInternalError, @@ -252,19 +254,10 @@ impl WebServer { .body(empty_body()) .unwrap()), (&Method::HEAD, Ok((key, code))) => { - handle_head_without_ctx(self.garage.clone(), req, bucket_id, key, code, None).await + handle_head(self.garage.clone(), req, bucket_id, key, code).await } (&Method::GET, Ok((key, code))) => { - handle_get_without_ctx( - self.garage.clone(), - req, - bucket_id, - key, - code, - None, - Default::default(), - ) - .await + handle_get(self.garage.clone(), req, bucket_id, key, code).await } _ => Err(ApiError::bad_request("HTTP method not supported")), }; @@ -306,25 +299,22 @@ impl WebServer { ) => { match *req.method() { Method::HEAD => { - handle_head_without_ctx( + handle_head( self.garage.clone(), req, bucket_id, redirect_key, *redirect_code, - None, ) .await } Method::GET => { - handle_get_without_ctx( + handle_get( self.garage.clone(), req, bucket_id, redirect_key, *redirect_code, - None, - Default::default(), ) .await } @@ -361,14 +351,12 @@ impl WebServer { .body(empty_body::()) .unwrap(); - match handle_get_without_ctx( + match handle_get( self.garage.clone(), &req2, bucket_id, &error_document, error.http_status_code(), - None, - Default::default(), ) .await { @@ -413,6 +401,63 @@ impl WebServer { } } +async fn handle_head( + garage: Arc, + req: &Request, + bucket_id: Uuid, + key: &str, + status_code: StatusCode, +) -> Result, ApiError> { + if status_code != StatusCode::OK { + // See comment in handle_get + let cleaned_req = Request::builder() + .uri(req.uri()) + .body(empty_body::()) + .unwrap(); + + let mut ret = handle_head_without_ctx(garage, &cleaned_req, bucket_id, key, None).await?; + *ret.status_mut() = status_code; + Ok(ret) + } else { + handle_head_without_ctx(garage, req, bucket_id, key, None).await + } +} + +pub async fn handle_get( + garage: Arc, + req: &Request, + bucket_id: Uuid, + key: &str, + status_code: StatusCode, +) -> Result, ApiError> { + if status_code != StatusCode::OK { + // If we are returning an error document, discard all headers from + // the original GET request that would have influenced the result: + // - Range header, we don't want to return a subrange of the error document + // - Caching directives such as If-None-Match, etc, which are not relevant + let cleaned_req = Request::builder() + .uri(req.uri()) + .body(empty_body::()) + .unwrap(); + + let mut ret = handle_get_without_ctx( + garage, + &cleaned_req, + bucket_id, + key, + None, + Default::default(), + ) + .await?; + + *ret.status_mut() = status_code; + + Ok(ret) + } else { + handle_get_without_ctx(garage, req, bucket_id, key, None, Default::default()).await + } +} + fn error_to_res(e: Error) -> Response> { // If we are here, it is either that: // - there was an error before trying to get the requested URL From 2aaba39ddc41df99ffc488a1377a82e8482be495 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 20:11:54 +0100 Subject: [PATCH 010/192] refactor web_server.rs --- src/web/web_server.rs | 103 ++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 65 deletions(-) diff --git a/src/web/web_server.rs b/src/web/web_server.rs index 23a21614..7a9eb1b4 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -253,13 +253,9 @@ impl WebServer { .header("Location", url) .body(empty_body()) .unwrap()), - (&Method::HEAD, Ok((key, code))) => { - handle_head(self.garage.clone(), req, bucket_id, key, code).await + (_, Ok((key, code))) => { + handle_inner(self.garage.clone(), req, bucket_id, key, code).await } - (&Method::GET, Ok((key, code))) => { - handle_get(self.garage.clone(), req, bucket_id, key, code).await - } - _ => Err(ApiError::bad_request("HTTP method not supported")), }; // Try handling errors if bucket configuration provided fallbacks @@ -297,30 +293,14 @@ impl WebServer { .. }, ) => { - match *req.method() { - Method::HEAD => { - handle_head( - self.garage.clone(), - req, - bucket_id, - redirect_key, - *redirect_code, - ) - .await - } - Method::GET => { - handle_get( - self.garage.clone(), - req, - bucket_id, - redirect_key, - *redirect_code, - ) - .await - } - // we shouldn't ever reach here - _ => Err(ApiError::bad_request("HTTP method not supported")), - } + handle_inner( + self.garage.clone(), + req, + bucket_id, + redirect_key, + *redirect_code, + ) + .await } _ => ret_doc, }; @@ -347,11 +327,12 @@ impl WebServer { // We want to return the error document // Create a fake HTTP request with path = the error document let req2 = Request::builder() + .method("GET") .uri(format!("http://{}/{}", host, &error_document)) .body(empty_body::()) .unwrap(); - match handle_get( + match handle_inner( self.garage.clone(), &req2, bucket_id, @@ -401,29 +382,7 @@ impl WebServer { } } -async fn handle_head( - garage: Arc, - req: &Request, - bucket_id: Uuid, - key: &str, - status_code: StatusCode, -) -> Result, ApiError> { - if status_code != StatusCode::OK { - // See comment in handle_get - let cleaned_req = Request::builder() - .uri(req.uri()) - .body(empty_body::()) - .unwrap(); - - let mut ret = handle_head_without_ctx(garage, &cleaned_req, bucket_id, key, None).await?; - *ret.status_mut() = status_code; - Ok(ret) - } else { - handle_head_without_ctx(garage, req, bucket_id, key, None).await - } -} - -pub async fn handle_get( +async fn handle_inner( garage: Arc, req: &Request, bucket_id: Uuid, @@ -432,7 +391,7 @@ pub async fn handle_get( ) -> Result, ApiError> { if status_code != StatusCode::OK { // If we are returning an error document, discard all headers from - // the original GET request that would have influenced the result: + // the original request that would have influenced the result: // - Range header, we don't want to return a subrange of the error document // - Caching directives such as If-None-Match, etc, which are not relevant let cleaned_req = Request::builder() @@ -440,21 +399,35 @@ pub async fn handle_get( .body(empty_body::()) .unwrap(); - let mut ret = handle_get_without_ctx( - garage, - &cleaned_req, - bucket_id, - key, - None, - Default::default(), - ) - .await?; + let mut ret = match req.method() { + &Method::HEAD => { + handle_head_without_ctx(garage, &cleaned_req, bucket_id, key, None).await? + } + &Method::GET => { + handle_get_without_ctx( + garage, + &cleaned_req, + bucket_id, + key, + None, + Default::default(), + ) + .await? + } + _ => return Err(ApiError::bad_request("HTTP method not supported")), + }; *ret.status_mut() = status_code; Ok(ret) } else { - handle_get_without_ctx(garage, req, bucket_id, key, None, Default::default()).await + match req.method() { + &Method::HEAD => handle_head_without_ctx(garage, req, bucket_id, key, None).await, + &Method::GET => { + handle_get_without_ctx(garage, req, bucket_id, key, None, Default::default()).await + } + _ => Err(ApiError::bad_request("HTTP method not supported")), + } } } From 5560a963e048f6bb000fc37b7e7ad73dbe96f3ab Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Wed, 15 May 2024 08:05:18 +0200 Subject: [PATCH 011/192] decrease write quorum --- src/table/replication/fullcopy.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/table/replication/fullcopy.rs b/src/table/replication/fullcopy.rs index 1e52bb47..39e29580 100644 --- a/src/table/replication/fullcopy.rs +++ b/src/table/replication/fullcopy.rs @@ -43,13 +43,10 @@ impl TableReplication for TableFullReplication { } fn write_quorum(&self) -> usize { let nmembers = self.system.cluster_layout().current().all_nodes().len(); - - let max_faults = if nmembers > 1 { 1 } else { 0 }; - - if nmembers > max_faults { - nmembers - max_faults - } else { + if nmembers < 3 { 1 + } else { + nmembers.div_euclid(2) + 1 } } From c1eb1610bab4d0d689dae9389f3fc10c0ab0efdc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 27 Jan 2025 23:13:01 +0100 Subject: [PATCH 012/192] admin api: create structs for all requests/responess in src/api/admin/api.rs --- src/api/admin/api.rs | 486 ++++++++++++++++++++++++++++++++++++ src/api/admin/api_server.rs | 12 +- src/api/admin/bucket.rs | 210 +++++----------- src/api/admin/cluster.rs | 374 +++++++++++---------------- src/api/admin/key.rs | 78 +----- src/api/admin/mod.rs | 16 ++ 6 files changed, 721 insertions(+), 455 deletions(-) create mode 100644 src/api/admin/api.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs new file mode 100644 index 00000000..a2dc95c2 --- /dev/null +++ b/src/api/admin/api.rs @@ -0,0 +1,486 @@ +use std::net::SocketAddr; + +use serde::{Deserialize, Serialize}; + +use crate::helpers::is_default; + +pub enum AdminApiRequest { + // Cluster operations + GetClusterStatus(GetClusterStatusRequest), + GetClusterHealth(GetClusterHealthRequest), + ConnectClusterNodes(ConnectClusterNodesRequest), + GetClusterLayout(GetClusterLayoutRequest), + UpdateClusterLayout(UpdateClusterLayoutRequest), + ApplyClusterLayout(ApplyClusterLayoutRequest), + RevertClusterLayout(RevertClusterLayoutRequest), +} + +pub enum AdminApiResponse { + // Cluster operations + GetClusterStatus(GetClusterStatusResponse), + GetClusterHealth(GetClusterHealthResponse), + ConnectClusterNodes(ConnectClusterNodesResponse), + GetClusterLayout(GetClusterLayoutResponse), + UpdateClusterLayout(UpdateClusterLayoutResponse), + ApplyClusterLayout(ApplyClusterLayoutResponse), + RevertClusterLayout(RevertClusterLayoutResponse), +} + +// ********************************************** +// Metrics-related endpoints +// ********************************************** + +// TODO: do we want this here ?? + +// ---- Metrics ---- + +pub struct MetricsRequest; + +// ---- Health ---- + +pub struct HealthRequest; + +// ********************************************** +// Cluster operations +// ********************************************** + +// ---- GetClusterStatus ---- + +pub struct GetClusterStatusRequest; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterStatusResponse { + pub node: String, + pub garage_version: &'static str, + pub garage_features: Option<&'static [&'static str]>, + pub rust_version: &'static str, + pub db_engine: String, + pub layout_version: u64, + pub nodes: Vec, +} + +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct NodeResp { + pub id: String, + pub role: Option, + pub addr: Option, + pub hostname: Option, + pub is_up: bool, + pub last_seen_secs_ago: Option, + pub draining: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_partition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_partition: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeRoleResp { + pub id: String, + pub zone: String, + pub capacity: Option, + pub tags: Vec, +} + +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct FreeSpaceResp { + pub available: u64, + pub total: u64, +} + +// ---- GetClusterHealth ---- + +pub struct GetClusterHealthRequest; + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterHealthResponse { + pub status: &'static str, + pub known_nodes: usize, + pub connected_nodes: usize, + pub storage_nodes: usize, + pub storage_nodes_ok: usize, + pub partitions: usize, + pub partitions_quorum: usize, + pub partitions_all_ok: usize, +} + +// ---- ConnectClusterNodes ---- + +#[derive(Debug, Clone, Deserialize)] +pub struct ConnectClusterNodesRequest(pub Vec); + +#[derive(Serialize)] +pub struct ConnectClusterNodesResponse(pub Vec); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectClusterNodeResponse { + pub success: bool, + pub error: Option, +} + +// ---- GetClusterLayout ---- + +pub struct GetClusterLayoutRequest; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterLayoutResponse { + pub version: u64, + pub roles: Vec, + pub staged_role_changes: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeRoleChange { + pub id: String, + #[serde(flatten)] + pub action: NodeRoleChangeEnum, +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum NodeRoleChangeEnum { + #[serde(rename_all = "camelCase")] + Remove { remove: bool }, + #[serde(rename_all = "camelCase")] + Update { + zone: String, + capacity: Option, + tags: Vec, + }, +} + +// ---- UpdateClusterLayout ---- + +#[derive(Deserialize)] +pub struct UpdateClusterLayoutRequest(pub Vec); + +#[derive(Serialize)] +pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); + +// ---- ApplyClusterLayout ---- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyClusterLayoutRequest { + pub version: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyClusterLayoutResponse { + pub message: Vec, + pub layout: GetClusterLayoutResponse, +} + +// ---- RevertClusterLayout ---- + +pub struct RevertClusterLayoutRequest; + +#[derive(Serialize)] +pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); + +// ********************************************** +// Access key operations +// ********************************************** + +// ---- ListKeys ---- + +pub struct ListKeysRequest; + +#[derive(Serialize)] +pub struct ListKeysResponse(pub Vec); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListKeysResponseItem { + pub id: String, + pub name: String, +} + +// ---- GetKeyInfo ---- + +pub struct GetKeyInfoRequest { + pub id: Option, + pub search: Option, + pub show_secret_key: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetKeyInfoResponse { + pub name: String, + pub access_key_id: String, + #[serde(skip_serializing_if = "is_default")] + pub secret_access_key: Option, + pub permissions: KeyPerm, + pub buckets: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyPerm { + #[serde(default)] + pub create_bucket: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyInfoBucketResponse { + pub id: String, + pub global_aliases: Vec, + pub local_aliases: Vec, + pub permissions: ApiBucketKeyPerm, +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ApiBucketKeyPerm { + #[serde(default)] + pub read: bool, + #[serde(default)] + pub write: bool, + #[serde(default)] + pub owner: bool, +} + +// ---- CreateKey ---- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateKeyRequest { + pub name: Option, +} + +#[derive(Serialize)] +pub struct CreateKeyResponse(pub GetKeyInfoResponse); + +// ---- ImportKey ---- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportKeyRequest { + pub access_key_id: String, + pub secret_access_key: String, + pub name: Option, +} + +#[derive(Serialize)] +pub struct ImportKeyResponse(pub GetKeyInfoResponse); + +// ---- UpdateKey ---- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateKeyRequest { + // TODO: id (get parameter) goes here + pub name: Option, + pub allow: Option, + pub deny: Option, +} + +#[derive(Serialize)] +pub struct UpdateKeyResponse(pub GetKeyInfoResponse); + +// ---- DeleteKey ---- + +pub struct DeleteKeyRequest { + pub id: String, +} + +pub struct DeleteKeyResponse; + +// ********************************************** +// Bucket operations +// ********************************************** + +// ---- ListBuckets ---- + +pub struct ListBucketsRequest; + +pub struct ListBucketsResponse(pub Vec); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListBucketsResponseItem { + pub id: String, + pub global_aliases: Vec, + pub local_aliases: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BucketLocalAlias { + pub access_key_id: String, + pub alias: String, +} + +// ---- GetBucketInfo ---- + +pub struct GetBucketInfoRequest { + pub id: Option, + pub global_alias: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetBucketInfoResponse { + pub id: String, + pub global_aliases: Vec, + pub website_access: bool, + #[serde(default)] + pub website_config: Option, + pub keys: Vec, + pub objects: i64, + pub bytes: i64, + pub unfinished_uploads: i64, + pub unfinished_multipart_uploads: i64, + pub unfinished_multipart_upload_parts: i64, + pub unfinished_multipart_upload_bytes: i64, + pub quotas: ApiBucketQuotas, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetBucketInfoWebsiteResponse { + pub index_document: String, + pub error_document: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetBucketInfoKey { + pub access_key_id: String, + pub name: String, + pub permissions: ApiBucketKeyPerm, + pub bucket_local_aliases: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiBucketQuotas { + pub max_size: Option, + pub max_objects: Option, +} + +// ---- CreateBucket ---- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBucketRequest { + pub global_alias: Option, + pub local_alias: Option, +} + +#[derive(Serialize)] +pub struct CreateBucketResponse(GetBucketInfoResponse); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBucketLocalAlias { + pub access_key_id: String, + pub alias: String, + #[serde(default)] + pub allow: ApiBucketKeyPerm, +} + +// ---- UpdateBucket ---- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBucketRequest { + pub website_access: Option, + pub quotas: Option, +} + +#[derive(Serialize)] +pub struct UpdateBucketResponse(GetBucketInfoResponse); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBucketWebsiteAccess { + pub enabled: bool, + pub index_document: Option, + pub error_document: Option, +} + +// ---- DeleteBucket ---- + +pub struct DeleteBucketRequest { + pub id: String, +} + +pub struct DeleteBucketResponse; + +// ********************************************** +// Operations on permissions for keys on buckets +// ********************************************** + +// ---- BucketAllowKey ---- + +pub struct BucketAllowKeyRequest(pub BucketKeyPermChangeRequest); + +pub struct BucketAllowKeyResponse; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BucketKeyPermChangeRequest { + pub bucket_id: String, + pub access_key_id: String, + pub permissions: ApiBucketKeyPerm, +} + +// ---- BucketDenyKey ---- + +pub struct BucketDenyKeyRequest(pub BucketKeyPermChangeRequest); + +pub struct BucketDenyKeyResponse; + +// ********************************************** +// Operations on bucket aliases +// ********************************************** + +// ---- GlobalAliasBucket ---- + +pub struct GlobalAliasBucketRequest { + pub id: String, + pub alias: String, +} + +pub struct GlobalAliasBucketReponse; + +// ---- GlobalUnaliasBucket ---- + +pub struct GlobalUnaliasBucketRequest { + pub id: String, + pub alias: String, +} + +pub struct GlobalUnaliasBucketReponse; + +// ---- LocalAliasBucket ---- + +pub struct LocalAliasBucketRequest { + pub id: String, + pub access_key_id: String, + pub alias: String, +} + +pub struct LocalAliasBucketReponse; + +// ---- LocalUnaliasBucket ---- + +pub struct LocalUnaliasBucketRequest { + pub id: String, + pub access_key_id: String, + pub alias: String, +} + +pub struct LocalUnaliasBucketReponse; diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 0e4565bb..9715292c 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -22,12 +22,14 @@ use garage_util::socket_address::UnixOrTCPSocketAddress; use crate::generic_server::*; +use crate::admin::api::*; use crate::admin::bucket::*; use crate::admin::cluster::*; use crate::admin::error::*; use crate::admin::key::*; use crate::admin::router_v0; use crate::admin::router_v1::{Authorization, Endpoint}; +use crate::admin::EndpointHandler; use crate::helpers::*; pub type ResBody = BoxBody; @@ -269,8 +271,14 @@ impl ApiHandler for AdminApiServer { Endpoint::CheckDomain => self.handle_check_domain(req).await, Endpoint::Health => self.handle_health(), Endpoint::Metrics => self.handle_metrics(), - Endpoint::GetClusterStatus => handle_get_cluster_status(&self.garage).await, - Endpoint::GetClusterHealth => handle_get_cluster_health(&self.garage).await, + Endpoint::GetClusterStatus => GetClusterStatusRequest + .handle(&self.garage) + .await + .and_then(|x| json_ok_response(&x)), + Endpoint::GetClusterHealth => GetClusterHealthRequest + .handle(&self.garage) + .await + .and_then(|x| json_ok_response(&x)), Endpoint::ConnectClusterNodes => handle_connect_cluster_nodes(&self.garage, req).await, // Layout Endpoint::GetClusterLayout => handle_get_cluster_layout(&self.garage).await, diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ac3cba00..593848f0 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; -use serde::{Deserialize, Serialize}; use garage_util::crdt::*; use garage_util::data::*; @@ -17,9 +16,14 @@ use garage_model::permission::*; use garage_model::s3::mpu_table; use garage_model::s3::object_table::*; +use crate::admin::api::ApiBucketKeyPerm; +use crate::admin::api::{ + ApiBucketQuotas, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, + GetBucketInfoKey, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, ListBucketsResponseItem, + UpdateBucketRequest, +}; use crate::admin::api_server::ResBody; use crate::admin::error::*; -use crate::admin::key::ApiBucketKeyPerm; use crate::common_error::CommonError; use crate::helpers::*; @@ -39,7 +43,7 @@ pub async fn handle_list_buckets(garage: &Arc) -> Result) -> Result, - local_aliases: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BucketLocalAlias { - access_key_id: String, - alias: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ApiBucketQuotas { - max_size: Option, - max_objects: Option, -} - pub async fn handle_get_bucket_info( garage: &Arc, id: Option, @@ -175,98 +157,63 @@ async fn bucket_info_results( let state = bucket.state.as_option().unwrap(); let quotas = state.quotas.get(); - let res = - GetBucketInfoResult { - id: hex::encode(bucket.id), - global_aliases: state - .aliases - .items() - .iter() - .filter(|(_, _, a)| *a) - .map(|(n, _, _)| n.to_string()) - .collect::>(), - website_access: state.website_config.get().is_some(), - website_config: state.website_config.get().clone().map(|wsc| { - GetBucketInfoWebsiteResult { - index_document: wsc.index_document, - error_document: wsc.error_document, + let res = GetBucketInfoResponse { + id: hex::encode(bucket.id), + global_aliases: state + .aliases + .items() + .iter() + .filter(|(_, _, a)| *a) + .map(|(n, _, _)| n.to_string()) + .collect::>(), + website_access: state.website_config.get().is_some(), + website_config: state.website_config.get().clone().map(|wsc| { + GetBucketInfoWebsiteResponse { + index_document: wsc.index_document, + error_document: wsc.error_document, + } + }), + keys: relevant_keys + .into_values() + .map(|key| { + let p = key.state.as_option().unwrap(); + GetBucketInfoKey { + access_key_id: key.key_id, + name: p.name.get().to_string(), + permissions: p + .authorized_buckets + .get(&bucket.id) + .map(|p| ApiBucketKeyPerm { + read: p.allow_read, + write: p.allow_write, + owner: p.allow_owner, + }) + .unwrap_or_default(), + bucket_local_aliases: p + .local_aliases + .items() + .iter() + .filter(|(_, _, b)| *b == Some(bucket.id)) + .map(|(n, _, _)| n.to_string()) + .collect::>(), } - }), - keys: relevant_keys - .into_values() - .map(|key| { - let p = key.state.as_option().unwrap(); - GetBucketInfoKey { - access_key_id: key.key_id, - name: p.name.get().to_string(), - permissions: p - .authorized_buckets - .get(&bucket.id) - .map(|p| ApiBucketKeyPerm { - read: p.allow_read, - write: p.allow_write, - owner: p.allow_owner, - }) - .unwrap_or_default(), - bucket_local_aliases: p - .local_aliases - .items() - .iter() - .filter(|(_, _, b)| *b == Some(bucket.id)) - .map(|(n, _, _)| n.to_string()) - .collect::>(), - } - }) - .collect::>(), - objects: *counters.get(OBJECTS).unwrap_or(&0), - bytes: *counters.get(BYTES).unwrap_or(&0), - unfinished_uploads: *counters.get(UNFINISHED_UPLOADS).unwrap_or(&0), - unfinished_multipart_uploads: *mpu_counters.get(mpu_table::UPLOADS).unwrap_or(&0), - unfinished_multipart_upload_parts: *mpu_counters.get(mpu_table::PARTS).unwrap_or(&0), - unfinished_multipart_upload_bytes: *mpu_counters.get(mpu_table::BYTES).unwrap_or(&0), - quotas: ApiBucketQuotas { - max_size: quotas.max_size, - max_objects: quotas.max_objects, - }, - }; + }) + .collect::>(), + objects: *counters.get(OBJECTS).unwrap_or(&0), + bytes: *counters.get(BYTES).unwrap_or(&0), + unfinished_uploads: *counters.get(UNFINISHED_UPLOADS).unwrap_or(&0), + unfinished_multipart_uploads: *mpu_counters.get(mpu_table::UPLOADS).unwrap_or(&0), + unfinished_multipart_upload_parts: *mpu_counters.get(mpu_table::PARTS).unwrap_or(&0), + unfinished_multipart_upload_bytes: *mpu_counters.get(mpu_table::BYTES).unwrap_or(&0), + quotas: ApiBucketQuotas { + max_size: quotas.max_size, + max_objects: quotas.max_objects, + }, + }; Ok(json_ok_response(&res)?) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GetBucketInfoResult { - id: String, - global_aliases: Vec, - website_access: bool, - #[serde(default)] - website_config: Option, - keys: Vec, - objects: i64, - bytes: i64, - unfinished_uploads: i64, - unfinished_multipart_uploads: i64, - unfinished_multipart_upload_parts: i64, - unfinished_multipart_upload_bytes: i64, - quotas: ApiBucketQuotas, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GetBucketInfoWebsiteResult { - index_document: String, - error_document: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GetBucketInfoKey { - access_key_id: String, - name: String, - permissions: ApiBucketKeyPerm, - bucket_local_aliases: Vec, -} - pub async fn handle_create_bucket( garage: &Arc, req: Request, @@ -336,22 +283,6 @@ pub async fn handle_create_bucket( bucket_info_results(garage, bucket.id).await } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateBucketRequest { - global_alias: Option, - local_alias: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateBucketLocalAlias { - access_key_id: String, - alias: String, - #[serde(default)] - allow: ApiBucketKeyPerm, -} - pub async fn handle_delete_bucket( garage: &Arc, id: String, @@ -446,21 +377,6 @@ pub async fn handle_update_bucket( bucket_info_results(garage, bucket_id).await } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateBucketRequest { - website_access: Option, - quotas: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateBucketWebsiteAccess { - enabled: bool, - index_document: Option, - error_document: Option, -} - // ---- BUCKET/KEY PERMISSIONS ---- pub async fn handle_bucket_change_key_perm( @@ -502,14 +418,6 @@ pub async fn handle_bucket_change_key_perm( bucket_info_results(garage, bucket.id).await } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct BucketKeyPermChangeRequest { - bucket_id: String, - access_key_id: String, - permissions: ApiBucketKeyPerm, -} - // ---- BUCKET ALIASES ---- pub async fn handle_global_alias_bucket( diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 357ac600..11753509 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -1,9 +1,8 @@ use std::collections::HashMap; -use std::net::SocketAddr; use std::sync::Arc; +use async_trait::async_trait; use hyper::{body::Incoming as IncomingBody, Request, Response}; -use serde::{Deserialize, Serialize}; use garage_util::crdt::*; use garage_util::data::*; @@ -12,153 +11,178 @@ use garage_rpc::layout; use garage_model::garage::Garage; +use crate::admin::api::{ + ApplyClusterLayoutRequest, ApplyClusterLayoutResponse, ConnectClusterNodeResponse, + ConnectClusterNodesRequest, ConnectClusterNodesResponse, FreeSpaceResp, + GetClusterHealthRequest, GetClusterHealthResponse, GetClusterLayoutResponse, + GetClusterStatusRequest, GetClusterStatusResponse, NodeResp, NodeRoleChange, + NodeRoleChangeEnum, NodeRoleResp, UpdateClusterLayoutRequest, +}; use crate::admin::api_server::ResBody; use crate::admin::error::*; +use crate::admin::EndpointHandler; use crate::helpers::{json_ok_response, parse_json_body}; -pub async fn handle_get_cluster_status(garage: &Arc) -> Result, Error> { - let layout = garage.system.cluster_layout(); - let mut nodes = garage - .system - .get_known_nodes() - .into_iter() - .map(|i| { - ( - i.id, - NodeResp { - id: hex::encode(i.id), - addr: i.addr, - hostname: i.status.hostname, - is_up: i.is_up, - last_seen_secs_ago: i.last_seen_secs_ago, - data_partition: i - .status - .data_disk_avail - .map(|(avail, total)| FreeSpaceResp { - available: avail, - total, +#[async_trait] +impl EndpointHandler for GetClusterStatusRequest { + type Response = GetClusterStatusResponse; + + async fn handle(self, garage: &Arc) -> Result { + let layout = garage.system.cluster_layout(); + let mut nodes = garage + .system + .get_known_nodes() + .into_iter() + .map(|i| { + ( + i.id, + NodeResp { + id: hex::encode(i.id), + addr: i.addr, + hostname: i.status.hostname, + is_up: i.is_up, + last_seen_secs_ago: i.last_seen_secs_ago, + data_partition: i.status.data_disk_avail.map(|(avail, total)| { + FreeSpaceResp { + available: avail, + total, + } }), - metadata_partition: i.status.meta_disk_avail.map(|(avail, total)| { - FreeSpaceResp { - available: avail, - total, - } - }), - ..Default::default() - }, - ) - }) - .collect::>(); + metadata_partition: i.status.meta_disk_avail.map(|(avail, total)| { + FreeSpaceResp { + available: avail, + total, + } + }), + ..Default::default() + }, + ) + }) + .collect::>(); - for (id, _, role) in layout.current().roles.items().iter() { - if let layout::NodeRoleV(Some(r)) = role { - let role = NodeRoleResp { - id: hex::encode(id), - zone: r.zone.to_string(), - capacity: r.capacity, - tags: r.tags.clone(), - }; - match nodes.get_mut(id) { - None => { - nodes.insert( - *id, - NodeResp { - id: hex::encode(id), - role: Some(role), - ..Default::default() - }, - ); - } - Some(n) => { - n.role = Some(role); - } - } - } - } - - for ver in layout.versions().iter().rev().skip(1) { - for (id, _, role) in ver.roles.items().iter() { + for (id, _, role) in layout.current().roles.items().iter() { if let layout::NodeRoleV(Some(r)) = role { - if r.capacity.is_some() { - if let Some(n) = nodes.get_mut(id) { - if n.role.is_none() { - n.draining = true; - } - } else { + let role = NodeRoleResp { + id: hex::encode(id), + zone: r.zone.to_string(), + capacity: r.capacity, + tags: r.tags.clone(), + }; + match nodes.get_mut(id) { + None => { nodes.insert( *id, NodeResp { id: hex::encode(id), - draining: true, + role: Some(role), ..Default::default() }, ); } + Some(n) => { + n.role = Some(role); + } } } } + + for ver in layout.versions().iter().rev().skip(1) { + for (id, _, role) in ver.roles.items().iter() { + if let layout::NodeRoleV(Some(r)) = role { + if r.capacity.is_some() { + if let Some(n) = nodes.get_mut(id) { + if n.role.is_none() { + n.draining = true; + } + } else { + nodes.insert( + *id, + NodeResp { + id: hex::encode(id), + draining: true, + ..Default::default() + }, + ); + } + } + } + } + } + + let mut nodes = nodes.into_values().collect::>(); + nodes.sort_by(|x, y| x.id.cmp(&y.id)); + + Ok(GetClusterStatusResponse { + node: hex::encode(garage.system.id), + garage_version: garage_util::version::garage_version(), + garage_features: garage_util::version::garage_features(), + rust_version: garage_util::version::rust_version(), + db_engine: garage.db.engine(), + layout_version: layout.current().version, + nodes, + }) } - - let mut nodes = nodes.into_values().collect::>(); - nodes.sort_by(|x, y| x.id.cmp(&y.id)); - - let res = GetClusterStatusResponse { - node: hex::encode(garage.system.id), - garage_version: garage_util::version::garage_version(), - garage_features: garage_util::version::garage_features(), - rust_version: garage_util::version::rust_version(), - db_engine: garage.db.engine(), - layout_version: layout.current().version, - nodes, - }; - - Ok(json_ok_response(&res)?) } -pub async fn handle_get_cluster_health(garage: &Arc) -> Result, Error> { - use garage_rpc::system::ClusterHealthStatus; - let health = garage.system.health(); - let health = ClusterHealth { - status: match health.status { - ClusterHealthStatus::Healthy => "healthy", - ClusterHealthStatus::Degraded => "degraded", - ClusterHealthStatus::Unavailable => "unavailable", - }, - known_nodes: health.known_nodes, - connected_nodes: health.connected_nodes, - storage_nodes: health.storage_nodes, - storage_nodes_ok: health.storage_nodes_ok, - partitions: health.partitions, - partitions_quorum: health.partitions_quorum, - partitions_all_ok: health.partitions_all_ok, - }; - Ok(json_ok_response(&health)?) +#[async_trait] +impl EndpointHandler for GetClusterHealthRequest { + type Response = GetClusterHealthResponse; + + async fn handle(self, garage: &Arc) -> Result { + use garage_rpc::system::ClusterHealthStatus; + let health = garage.system.health(); + let health = GetClusterHealthResponse { + status: match health.status { + ClusterHealthStatus::Healthy => "healthy", + ClusterHealthStatus::Degraded => "degraded", + ClusterHealthStatus::Unavailable => "unavailable", + }, + known_nodes: health.known_nodes, + connected_nodes: health.connected_nodes, + storage_nodes: health.storage_nodes, + storage_nodes_ok: health.storage_nodes_ok, + partitions: health.partitions, + partitions_quorum: health.partitions_quorum, + partitions_all_ok: health.partitions_all_ok, + }; + Ok(health) + } } pub async fn handle_connect_cluster_nodes( garage: &Arc, req: Request, ) -> Result, Error> { - let req = parse_json_body::, _, Error>(req).await?; + let req = parse_json_body::(req).await?; - let res = futures::future::join_all(req.iter().map(|node| garage.system.connect(node))) - .await - .into_iter() - .map(|r| match r { - Ok(()) => ConnectClusterNodesResponse { - success: true, - error: None, - }, - Err(e) => ConnectClusterNodesResponse { - success: false, - error: Some(format!("{}", e)), - }, - }) - .collect::>(); + let res = req.handle(garage).await?; Ok(json_ok_response(&res)?) } +#[async_trait] +impl EndpointHandler for ConnectClusterNodesRequest { + type Response = ConnectClusterNodesResponse; + + async fn handle(self, garage: &Arc) -> Result { + let res = futures::future::join_all(self.0.iter().map(|node| garage.system.connect(node))) + .await + .into_iter() + .map(|r| match r { + Ok(()) => ConnectClusterNodeResponse { + success: true, + error: None, + }, + Err(e) => ConnectClusterNodeResponse { + success: false, + error: Some(format!("{}", e)), + }, + }) + .collect::>(); + Ok(ConnectClusterNodesResponse(res)) + } +} + pub async fn handle_get_cluster_layout(garage: &Arc) -> Result, Error> { let res = format_cluster_layout(garage.system.cluster_layout().inner()); @@ -212,85 +236,6 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp // ---- -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClusterHealth { - status: &'static str, - known_nodes: usize, - connected_nodes: usize, - storage_nodes: usize, - storage_nodes_ok: usize, - partitions: usize, - partitions_quorum: usize, - partitions_all_ok: usize, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GetClusterStatusResponse { - node: String, - garage_version: &'static str, - garage_features: Option<&'static [&'static str]>, - rust_version: &'static str, - db_engine: String, - layout_version: u64, - nodes: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ApplyClusterLayoutResponse { - message: Vec, - layout: GetClusterLayoutResponse, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ConnectClusterNodesResponse { - success: bool, - error: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GetClusterLayoutResponse { - version: u64, - roles: Vec, - staged_role_changes: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct NodeRoleResp { - id: String, - zone: String, - capacity: Option, - tags: Vec, -} - -#[derive(Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct FreeSpaceResp { - available: u64, - total: u64, -} - -#[derive(Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct NodeResp { - id: String, - role: Option, - addr: Option, - hostname: Option, - is_up: bool, - last_seen_secs_ago: Option, - draining: bool, - #[serde(skip_serializing_if = "Option::is_none")] - data_partition: Option, - #[serde(skip_serializing_if = "Option::is_none")] - metadata_partition: Option, -} - // ---- update functions ---- pub async fn handle_update_cluster_layout( @@ -304,7 +249,7 @@ pub async fn handle_update_cluster_layout( let mut roles = layout.current().roles.clone(); roles.merge(&layout.staging.get().roles); - for change in updates { + for change in updates.0 { let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?; let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?; @@ -343,7 +288,7 @@ pub async fn handle_apply_cluster_layout( garage: &Arc, req: Request, ) -> Result, Error> { - let param = parse_json_body::(req).await?; + let param = parse_json_body::(req).await?; let layout = garage.system.cluster_layout().inner().clone(); let (layout, msg) = layout.apply_staged_changes(Some(param.version))?; @@ -375,36 +320,3 @@ pub async fn handle_revert_cluster_layout( let res = format_cluster_layout(&layout); Ok(json_ok_response(&res)?) } - -// ---- - -type UpdateClusterLayoutRequest = Vec; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ApplyLayoutRequest { - version: u64, -} - -// ---- - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct NodeRoleChange { - id: String, - #[serde(flatten)] - action: NodeRoleChangeEnum, -} - -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -enum NodeRoleChangeEnum { - #[serde(rename_all = "camelCase")] - Remove { remove: bool }, - #[serde(rename_all = "camelCase")] - Update { - zone: String, - capacity: Option, - tags: Vec, - }, -} diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 291b6d54..96ce3518 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -2,13 +2,16 @@ use std::collections::HashMap; use std::sync::Arc; use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; -use serde::{Deserialize, Serialize}; use garage_table::*; use garage_model::garage::Garage; use garage_model::key_table::*; +use crate::admin::api::{ + ApiBucketKeyPerm, CreateKeyRequest, GetKeyInfoResponse, ImportKeyRequest, + KeyInfoBucketResponse, KeyPerm, ListKeysResponseItem, UpdateKeyRequest, +}; use crate::admin::api_server::ResBody; use crate::admin::error::*; use crate::helpers::*; @@ -25,7 +28,7 @@ pub async fn handle_list_keys(garage: &Arc) -> Result, ) .await? .iter() - .map(|k| ListKeyResultItem { + .map(|k| ListKeysResponseItem { id: k.key_id.to_string(), name: k.params().unwrap().name.get().clone(), }) @@ -34,13 +37,6 @@ pub async fn handle_list_keys(garage: &Arc) -> Result, Ok(json_ok_response(&res)?) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ListKeyResultItem { - id: String, - name: String, -} - pub async fn handle_get_key_info( garage: &Arc, id: Option, @@ -73,12 +69,6 @@ pub async fn handle_create_key( key_info_results(garage, key, true).await } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateKeyRequest { - name: Option, -} - pub async fn handle_import_key( garage: &Arc, req: Request, @@ -101,14 +91,6 @@ pub async fn handle_import_key( key_info_results(garage, imported_key, false).await } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ImportKeyRequest { - access_key_id: String, - secret_access_key: String, - name: Option, -} - pub async fn handle_update_key( garage: &Arc, id: String, @@ -139,14 +121,6 @@ pub async fn handle_update_key( key_info_results(garage, key, false).await } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateKeyRequest { - name: Option, - allow: Option, - deny: Option, -} - pub async fn handle_delete_key( garage: &Arc, id: String, @@ -192,7 +166,7 @@ async fn key_info_results( } } - let res = GetKeyInfoResult { + let res = GetKeyInfoResponse { name: key_state.name.get().clone(), access_key_id: key.key_id.clone(), secret_access_key: if show_secret { @@ -207,7 +181,7 @@ async fn key_info_results( .into_values() .map(|bucket| { let state = bucket.state.as_option().unwrap(); - KeyInfoBucketResult { + KeyInfoBucketResponse { id: hex::encode(bucket.id), global_aliases: state .aliases @@ -239,41 +213,3 @@ async fn key_info_results( Ok(json_ok_response(&res)?) } - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GetKeyInfoResult { - name: String, - access_key_id: String, - #[serde(skip_serializing_if = "is_default")] - secret_access_key: Option, - permissions: KeyPerm, - buckets: Vec, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KeyPerm { - #[serde(default)] - create_bucket: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct KeyInfoBucketResult { - id: String, - global_aliases: Vec, - local_aliases: Vec, - permissions: ApiBucketKeyPerm, -} - -#[derive(Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ApiBucketKeyPerm { - #[serde(default)] - pub(crate) read: bool, - #[serde(default)] - pub(crate) write: bool, - #[serde(default)] - pub(crate) owner: bool, -} diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 43a8c59c..e64eca7e 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -1,8 +1,24 @@ pub mod api_server; mod error; + +pub mod api; mod router_v0; mod router_v1; mod bucket; mod cluster; mod key; + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::Serialize; + +use garage_model::garage::Garage; + +#[async_trait] +pub trait EndpointHandler { + type Response: Serialize; + + async fn handle(self, garage: &Arc) -> Result; +} From 831f2b0207f128d67f061e6f7084337b1cbfefa4 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 00:22:14 +0100 Subject: [PATCH 013/192] admin api: make all handlers impls of a single trait --- src/api/admin/api.rs | 176 ++++++++++-- src/api/admin/api_server.rs | 182 ++++++++---- src/api/admin/bucket.rs | 550 +++++++++++++++++++----------------- src/api/admin/cluster.rs | 168 ++++++----- src/api/admin/key.rs | 227 ++++++++------- 5 files changed, 781 insertions(+), 522 deletions(-) diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index a2dc95c2..a5dbdfbe 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -1,7 +1,13 @@ use std::net::SocketAddr; +use std::sync::Arc; +use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use garage_model::garage::Garage; + +use crate::admin::error::Error; +use crate::admin::EndpointHandler; use crate::helpers::is_default; pub enum AdminApiRequest { @@ -13,8 +19,35 @@ pub enum AdminApiRequest { UpdateClusterLayout(UpdateClusterLayoutRequest), ApplyClusterLayout(ApplyClusterLayoutRequest), RevertClusterLayout(RevertClusterLayoutRequest), + + // Access key operations + ListKeys(ListKeysRequest), + GetKeyInfo(GetKeyInfoRequest), + CreateKey(CreateKeyRequest), + ImportKey(ImportKeyRequest), + UpdateKey(UpdateKeyRequest), + DeleteKey(DeleteKeyRequest), + + // Bucket operations + ListBuckets(ListBucketsRequest), + GetBucketInfo(GetBucketInfoRequest), + CreateBucket(CreateBucketRequest), + UpdateBucket(UpdateBucketRequest), + DeleteBucket(DeleteBucketRequest), + + // Operations on permissions for keys on buckets + BucketAllowKey(BucketAllowKeyRequest), + BucketDenyKey(BucketDenyKeyRequest), + + // Operations on bucket aliases + GlobalAliasBucket(GlobalAliasBucketRequest), + GlobalUnaliasBucket(GlobalUnaliasBucketRequest), + LocalAliasBucket(LocalAliasBucketRequest), + LocalUnaliasBucket(LocalUnaliasBucketRequest), } +#[derive(Serialize)] +#[serde(untagged)] pub enum AdminApiResponse { // Cluster operations GetClusterStatus(GetClusterStatusResponse), @@ -24,6 +57,98 @@ pub enum AdminApiResponse { UpdateClusterLayout(UpdateClusterLayoutResponse), ApplyClusterLayout(ApplyClusterLayoutResponse), RevertClusterLayout(RevertClusterLayoutResponse), + + // Access key operations + ListKeys(ListKeysResponse), + GetKeyInfo(GetKeyInfoResponse), + CreateKey(CreateKeyResponse), + ImportKey(ImportKeyResponse), + UpdateKey(UpdateKeyResponse), + DeleteKey(DeleteKeyResponse), + + // Bucket operations + ListBuckets(ListBucketsResponse), + GetBucketInfo(GetBucketInfoResponse), + CreateBucket(CreateBucketResponse), + UpdateBucket(UpdateBucketResponse), + DeleteBucket(DeleteBucketResponse), + + // Operations on permissions for keys on buckets + BucketAllowKey(BucketAllowKeyResponse), + BucketDenyKey(BucketDenyKeyResponse), + + // Operations on bucket aliases + GlobalAliasBucket(GlobalAliasBucketResponse), + GlobalUnaliasBucket(GlobalUnaliasBucketResponse), + LocalAliasBucket(LocalAliasBucketResponse), + LocalUnaliasBucket(LocalUnaliasBucketResponse), +} + +#[async_trait] +impl EndpointHandler for AdminApiRequest { + type Response = AdminApiResponse; + + async fn handle(self, garage: &Arc) -> Result { + Ok(match self { + // Cluster operations + Self::GetClusterStatus(req) => { + AdminApiResponse::GetClusterStatus(req.handle(garage).await?) + } + Self::GetClusterHealth(req) => { + AdminApiResponse::GetClusterHealth(req.handle(garage).await?) + } + Self::ConnectClusterNodes(req) => { + AdminApiResponse::ConnectClusterNodes(req.handle(garage).await?) + } + Self::GetClusterLayout(req) => { + AdminApiResponse::GetClusterLayout(req.handle(garage).await?) + } + Self::UpdateClusterLayout(req) => { + AdminApiResponse::UpdateClusterLayout(req.handle(garage).await?) + } + Self::ApplyClusterLayout(req) => { + AdminApiResponse::ApplyClusterLayout(req.handle(garage).await?) + } + Self::RevertClusterLayout(req) => { + AdminApiResponse::RevertClusterLayout(req.handle(garage).await?) + } + + // Access key operations + Self::ListKeys(req) => AdminApiResponse::ListKeys(req.handle(garage).await?), + Self::GetKeyInfo(req) => AdminApiResponse::GetKeyInfo(req.handle(garage).await?), + Self::CreateKey(req) => AdminApiResponse::CreateKey(req.handle(garage).await?), + Self::ImportKey(req) => AdminApiResponse::ImportKey(req.handle(garage).await?), + Self::UpdateKey(req) => AdminApiResponse::UpdateKey(req.handle(garage).await?), + Self::DeleteKey(req) => AdminApiResponse::DeleteKey(req.handle(garage).await?), + + // Bucket operations + Self::ListBuckets(req) => AdminApiResponse::ListBuckets(req.handle(garage).await?), + Self::GetBucketInfo(req) => AdminApiResponse::GetBucketInfo(req.handle(garage).await?), + Self::CreateBucket(req) => AdminApiResponse::CreateBucket(req.handle(garage).await?), + Self::UpdateBucket(req) => AdminApiResponse::UpdateBucket(req.handle(garage).await?), + Self::DeleteBucket(req) => AdminApiResponse::DeleteBucket(req.handle(garage).await?), + + // Operations on permissions for keys on buckets + Self::BucketAllowKey(req) => { + AdminApiResponse::BucketAllowKey(req.handle(garage).await?) + } + Self::BucketDenyKey(req) => AdminApiResponse::BucketDenyKey(req.handle(garage).await?), + + // Operations on bucket aliases + Self::GlobalAliasBucket(req) => { + AdminApiResponse::GlobalAliasBucket(req.handle(garage).await?) + } + Self::GlobalUnaliasBucket(req) => { + AdminApiResponse::GlobalUnaliasBucket(req.handle(garage).await?) + } + Self::LocalAliasBucket(req) => { + AdminApiResponse::LocalAliasBucket(req.handle(garage).await?) + } + Self::LocalUnaliasBucket(req) => { + AdminApiResponse::LocalUnaliasBucket(req.handle(garage).await?) + } + }) + } } // ********************************************** @@ -277,24 +402,30 @@ pub struct ImportKeyResponse(pub GetKeyInfoResponse); // ---- UpdateKey ---- +pub struct UpdateKeyRequest { + pub id: String, + pub params: UpdateKeyRequestParams, +} + +#[derive(Serialize)] +pub struct UpdateKeyResponse(pub GetKeyInfoResponse); + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UpdateKeyRequest { +pub struct UpdateKeyRequestParams { // TODO: id (get parameter) goes here pub name: Option, pub allow: Option, pub deny: Option, } -#[derive(Serialize)] -pub struct UpdateKeyResponse(pub GetKeyInfoResponse); - // ---- DeleteKey ---- pub struct DeleteKeyRequest { pub id: String, } +#[derive(Serialize)] pub struct DeleteKeyResponse; // ********************************************** @@ -305,6 +436,7 @@ pub struct DeleteKeyResponse; pub struct ListBucketsRequest; +#[derive(Serialize)] pub struct ListBucketsResponse(pub Vec); #[derive(Serialize)] @@ -380,7 +512,7 @@ pub struct CreateBucketRequest { } #[derive(Serialize)] -pub struct CreateBucketResponse(GetBucketInfoResponse); +pub struct CreateBucketResponse(pub GetBucketInfoResponse); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -393,15 +525,20 @@ pub struct CreateBucketLocalAlias { // ---- UpdateBucket ---- -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] pub struct UpdateBucketRequest { - pub website_access: Option, - pub quotas: Option, + pub id: String, + pub params: UpdateBucketRequestParams, } #[derive(Serialize)] -pub struct UpdateBucketResponse(GetBucketInfoResponse); +pub struct UpdateBucketResponse(pub GetBucketInfoResponse); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBucketRequestParams { + pub website_access: Option, + pub quotas: Option, +} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -417,6 +554,7 @@ pub struct DeleteBucketRequest { pub id: String, } +#[derive(Serialize)] pub struct DeleteBucketResponse; // ********************************************** @@ -427,7 +565,8 @@ pub struct DeleteBucketResponse; pub struct BucketAllowKeyRequest(pub BucketKeyPermChangeRequest); -pub struct BucketAllowKeyResponse; +#[derive(Serialize)] +pub struct BucketAllowKeyResponse(pub GetBucketInfoResponse); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -441,7 +580,8 @@ pub struct BucketKeyPermChangeRequest { pub struct BucketDenyKeyRequest(pub BucketKeyPermChangeRequest); -pub struct BucketDenyKeyResponse; +#[derive(Serialize)] +pub struct BucketDenyKeyResponse(pub GetBucketInfoResponse); // ********************************************** // Operations on bucket aliases @@ -454,7 +594,8 @@ pub struct GlobalAliasBucketRequest { pub alias: String, } -pub struct GlobalAliasBucketReponse; +#[derive(Serialize)] +pub struct GlobalAliasBucketResponse(pub GetBucketInfoResponse); // ---- GlobalUnaliasBucket ---- @@ -463,7 +604,8 @@ pub struct GlobalUnaliasBucketRequest { pub alias: String, } -pub struct GlobalUnaliasBucketReponse; +#[derive(Serialize)] +pub struct GlobalUnaliasBucketResponse(pub GetBucketInfoResponse); // ---- LocalAliasBucket ---- @@ -473,7 +615,8 @@ pub struct LocalAliasBucketRequest { pub alias: String, } -pub struct LocalAliasBucketReponse; +#[derive(Serialize)] +pub struct LocalAliasBucketResponse(pub GetBucketInfoResponse); // ---- LocalUnaliasBucket ---- @@ -483,4 +626,5 @@ pub struct LocalUnaliasBucketRequest { pub alias: String, } -pub struct LocalUnaliasBucketReponse; +#[derive(Serialize)] +pub struct LocalUnaliasBucketResponse(pub GetBucketInfoResponse); diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 9715292c..c6b7661c 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -23,10 +23,7 @@ use garage_util::socket_address::UnixOrTCPSocketAddress; use crate::generic_server::*; use crate::admin::api::*; -use crate::admin::bucket::*; -use crate::admin::cluster::*; use crate::admin::error::*; -use crate::admin::key::*; use crate::admin::router_v0; use crate::admin::router_v1::{Authorization, Endpoint}; use crate::admin::EndpointHandler; @@ -271,67 +268,134 @@ impl ApiHandler for AdminApiServer { Endpoint::CheckDomain => self.handle_check_domain(req).await, Endpoint::Health => self.handle_health(), Endpoint::Metrics => self.handle_metrics(), - Endpoint::GetClusterStatus => GetClusterStatusRequest - .handle(&self.garage) + e => { + async { + let body = parse_request_body(e, req).await?; + let res = body.handle(&self.garage).await?; + json_ok_response(&res) + } .await - .and_then(|x| json_ok_response(&x)), - Endpoint::GetClusterHealth => GetClusterHealthRequest - .handle(&self.garage) - .await - .and_then(|x| json_ok_response(&x)), - Endpoint::ConnectClusterNodes => handle_connect_cluster_nodes(&self.garage, req).await, - // Layout - Endpoint::GetClusterLayout => handle_get_cluster_layout(&self.garage).await, - Endpoint::UpdateClusterLayout => handle_update_cluster_layout(&self.garage, req).await, - Endpoint::ApplyClusterLayout => handle_apply_cluster_layout(&self.garage, req).await, - Endpoint::RevertClusterLayout => handle_revert_cluster_layout(&self.garage).await, - // Keys - Endpoint::ListKeys => handle_list_keys(&self.garage).await, - Endpoint::GetKeyInfo { + } + } + } +} + +async fn parse_request_body( + endpoint: Endpoint, + req: Request, +) -> Result { + match endpoint { + Endpoint::GetClusterStatus => { + Ok(AdminApiRequest::GetClusterStatus(GetClusterStatusRequest)) + } + Endpoint::GetClusterHealth => { + Ok(AdminApiRequest::GetClusterHealth(GetClusterHealthRequest)) + } + Endpoint::ConnectClusterNodes => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::ConnectClusterNodes(req)) + } + // Layout + Endpoint::GetClusterLayout => { + Ok(AdminApiRequest::GetClusterLayout(GetClusterLayoutRequest)) + } + Endpoint::UpdateClusterLayout => { + let updates = parse_json_body::(req).await?; + Ok(AdminApiRequest::UpdateClusterLayout(updates)) + } + Endpoint::ApplyClusterLayout => { + let param = parse_json_body::(req).await?; + Ok(AdminApiRequest::ApplyClusterLayout(param)) + } + Endpoint::RevertClusterLayout => Ok(AdminApiRequest::RevertClusterLayout( + RevertClusterLayoutRequest, + )), + // Keys + Endpoint::ListKeys => Ok(AdminApiRequest::ListKeys(ListKeysRequest)), + Endpoint::GetKeyInfo { + id, + search, + show_secret_key, + } => { + let show_secret_key = show_secret_key.map(|x| x == "true").unwrap_or(false); + Ok(AdminApiRequest::GetKeyInfo(GetKeyInfoRequest { id, search, show_secret_key, - } => { - let show_secret_key = show_secret_key.map(|x| x == "true").unwrap_or(false); - handle_get_key_info(&self.garage, id, search, show_secret_key).await - } - Endpoint::CreateKey => handle_create_key(&self.garage, req).await, - Endpoint::ImportKey => handle_import_key(&self.garage, req).await, - Endpoint::UpdateKey { id } => handle_update_key(&self.garage, id, req).await, - Endpoint::DeleteKey { id } => handle_delete_key(&self.garage, id).await, - // Buckets - Endpoint::ListBuckets => handle_list_buckets(&self.garage).await, - Endpoint::GetBucketInfo { id, global_alias } => { - handle_get_bucket_info(&self.garage, id, global_alias).await - } - Endpoint::CreateBucket => handle_create_bucket(&self.garage, req).await, - Endpoint::DeleteBucket { id } => handle_delete_bucket(&self.garage, id).await, - Endpoint::UpdateBucket { id } => handle_update_bucket(&self.garage, id, req).await, - // Bucket-key permissions - Endpoint::BucketAllowKey => { - handle_bucket_change_key_perm(&self.garage, req, true).await - } - Endpoint::BucketDenyKey => { - handle_bucket_change_key_perm(&self.garage, req, false).await - } - // Bucket aliasing - Endpoint::GlobalAliasBucket { id, alias } => { - handle_global_alias_bucket(&self.garage, id, alias).await - } - Endpoint::GlobalUnaliasBucket { id, alias } => { - handle_global_unalias_bucket(&self.garage, id, alias).await - } - Endpoint::LocalAliasBucket { - id, - access_key_id, - alias, - } => handle_local_alias_bucket(&self.garage, id, access_key_id, alias).await, - Endpoint::LocalUnaliasBucket { - id, - access_key_id, - alias, - } => handle_local_unalias_bucket(&self.garage, id, access_key_id, alias).await, + })) } + Endpoint::CreateKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::CreateKey(req)) + } + Endpoint::ImportKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::ImportKey(req)) + } + Endpoint::UpdateKey { id } => { + let params = parse_json_body::(req).await?; + Ok(AdminApiRequest::UpdateKey(UpdateKeyRequest { id, params })) + } + Endpoint::DeleteKey { id } => Ok(AdminApiRequest::DeleteKey(DeleteKeyRequest { id })), + // Buckets + Endpoint::ListBuckets => Ok(AdminApiRequest::ListBuckets(ListBucketsRequest)), + Endpoint::GetBucketInfo { id, global_alias } => { + Ok(AdminApiRequest::GetBucketInfo(GetBucketInfoRequest { + id, + global_alias, + })) + } + Endpoint::CreateBucket => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::CreateBucket(req)) + } + Endpoint::DeleteBucket { id } => { + Ok(AdminApiRequest::DeleteBucket(DeleteBucketRequest { id })) + } + Endpoint::UpdateBucket { id } => { + let params = parse_json_body::(req).await?; + Ok(AdminApiRequest::UpdateBucket(UpdateBucketRequest { + id, + params, + })) + } + // Bucket-key permissions + Endpoint::BucketAllowKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::BucketAllowKey(BucketAllowKeyRequest(req))) + } + Endpoint::BucketDenyKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::BucketDenyKey(BucketDenyKeyRequest(req))) + } + // Bucket aliasing + Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::GlobalAliasBucket( + GlobalAliasBucketRequest { id, alias }, + )), + Endpoint::GlobalUnaliasBucket { id, alias } => Ok(AdminApiRequest::GlobalUnaliasBucket( + GlobalUnaliasBucketRequest { id, alias }, + )), + Endpoint::LocalAliasBucket { + id, + access_key_id, + alias, + } => Ok(AdminApiRequest::LocalAliasBucket(LocalAliasBucketRequest { + access_key_id, + id, + alias, + })), + Endpoint::LocalUnaliasBucket { + id, + access_key_id, + alias, + } => Ok(AdminApiRequest::LocalUnaliasBucket( + LocalUnaliasBucketRequest { + access_key_id, + id, + alias, + }, + )), + _ => unreachable!(), } } diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 593848f0..d62bfa54 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; +use async_trait::async_trait; use garage_util::crdt::*; use garage_util::data::*; @@ -18,83 +18,93 @@ use garage_model::s3::object_table::*; use crate::admin::api::ApiBucketKeyPerm; use crate::admin::api::{ - ApiBucketQuotas, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, - GetBucketInfoKey, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, ListBucketsResponseItem, - UpdateBucketRequest, + ApiBucketQuotas, BucketAllowKeyRequest, BucketAllowKeyResponse, BucketDenyKeyRequest, + BucketDenyKeyResponse, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, + CreateBucketResponse, DeleteBucketRequest, DeleteBucketResponse, GetBucketInfoKey, + GetBucketInfoRequest, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, + GlobalAliasBucketRequest, GlobalAliasBucketResponse, GlobalUnaliasBucketRequest, + GlobalUnaliasBucketResponse, ListBucketsRequest, ListBucketsResponse, ListBucketsResponseItem, + LocalAliasBucketRequest, LocalAliasBucketResponse, LocalUnaliasBucketRequest, + LocalUnaliasBucketResponse, UpdateBucketRequest, UpdateBucketResponse, }; -use crate::admin::api_server::ResBody; use crate::admin::error::*; +use crate::admin::EndpointHandler; use crate::common_error::CommonError; -use crate::helpers::*; -pub async fn handle_list_buckets(garage: &Arc) -> Result, Error> { - let buckets = garage - .bucket_table - .get_range( - &EmptyKey, - None, - Some(DeletedFilter::NotDeleted), - 10000, - EnumerationOrder::Forward, - ) - .await?; +#[async_trait] +impl EndpointHandler for ListBucketsRequest { + type Response = ListBucketsResponse; - let res = buckets - .into_iter() - .map(|b| { - let state = b.state.as_option().unwrap(); - ListBucketsResponseItem { - id: hex::encode(b.id), - global_aliases: state - .aliases - .items() - .iter() - .filter(|(_, _, a)| *a) - .map(|(n, _, _)| n.to_string()) - .collect::>(), - local_aliases: state - .local_aliases - .items() - .iter() - .filter(|(_, _, a)| *a) - .map(|((k, n), _, _)| BucketLocalAlias { - access_key_id: k.to_string(), - alias: n.to_string(), - }) - .collect::>(), - } - }) - .collect::>(); + async fn handle(self, garage: &Arc) -> Result { + let buckets = garage + .bucket_table + .get_range( + &EmptyKey, + None, + Some(DeletedFilter::NotDeleted), + 10000, + EnumerationOrder::Forward, + ) + .await?; - Ok(json_ok_response(&res)?) + let res = buckets + .into_iter() + .map(|b| { + let state = b.state.as_option().unwrap(); + ListBucketsResponseItem { + id: hex::encode(b.id), + global_aliases: state + .aliases + .items() + .iter() + .filter(|(_, _, a)| *a) + .map(|(n, _, _)| n.to_string()) + .collect::>(), + local_aliases: state + .local_aliases + .items() + .iter() + .filter(|(_, _, a)| *a) + .map(|((k, n), _, _)| BucketLocalAlias { + access_key_id: k.to_string(), + alias: n.to_string(), + }) + .collect::>(), + } + }) + .collect::>(); + + Ok(ListBucketsResponse(res)) + } } -pub async fn handle_get_bucket_info( - garage: &Arc, - id: Option, - global_alias: Option, -) -> Result, Error> { - let bucket_id = match (id, global_alias) { - (Some(id), None) => parse_bucket_id(&id)?, - (None, Some(ga)) => garage - .bucket_helper() - .resolve_global_bucket_name(&ga) - .await? - .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, - _ => { - return Err(Error::bad_request( - "Either id or globalAlias must be provided (but not both)", - )); - } - }; +#[async_trait] +impl EndpointHandler for GetBucketInfoRequest { + type Response = GetBucketInfoResponse; - bucket_info_results(garage, bucket_id).await + async fn handle(self, garage: &Arc) -> Result { + let bucket_id = match (self.id, self.global_alias) { + (Some(id), None) => parse_bucket_id(&id)?, + (None, Some(ga)) => garage + .bucket_helper() + .resolve_global_bucket_name(&ga) + .await? + .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, + _ => { + return Err(Error::bad_request( + "Either id or globalAlias must be provided (but not both)", + )); + } + }; + + bucket_info_results(garage, bucket_id).await + } } async fn bucket_info_results( garage: &Arc, bucket_id: Uuid, -) -> Result, Error> { +) -> Result { let bucket = garage .bucket_helper() .get_existing_bucket(bucket_id) @@ -211,181 +221,203 @@ async fn bucket_info_results( }, }; - Ok(json_ok_response(&res)?) + Ok(res) } -pub async fn handle_create_bucket( - garage: &Arc, - req: Request, -) -> Result, Error> { - let req = parse_json_body::(req).await?; +#[async_trait] +impl EndpointHandler for CreateBucketRequest { + type Response = CreateBucketResponse; - let helper = garage.locked_helper().await; + async fn handle(self, garage: &Arc) -> Result { + let helper = garage.locked_helper().await; - if let Some(ga) = &req.global_alias { - if !is_valid_bucket_name(ga) { - return Err(Error::bad_request(format!( - "{}: {}", - ga, INVALID_BUCKET_NAME_MESSAGE - ))); - } + if let Some(ga) = &self.global_alias { + if !is_valid_bucket_name(ga) { + return Err(Error::bad_request(format!( + "{}: {}", + ga, INVALID_BUCKET_NAME_MESSAGE + ))); + } - if let Some(alias) = garage.bucket_alias_table.get(&EmptyKey, ga).await? { - if alias.state.get().is_some() { - return Err(CommonError::BucketAlreadyExists.into()); + if let Some(alias) = garage.bucket_alias_table.get(&EmptyKey, ga).await? { + if alias.state.get().is_some() { + return Err(CommonError::BucketAlreadyExists.into()); + } } } - } - if let Some(la) = &req.local_alias { - if !is_valid_bucket_name(&la.alias) { - return Err(Error::bad_request(format!( - "{}: {}", - la.alias, INVALID_BUCKET_NAME_MESSAGE - ))); + if let Some(la) = &self.local_alias { + if !is_valid_bucket_name(&la.alias) { + return Err(Error::bad_request(format!( + "{}: {}", + la.alias, INVALID_BUCKET_NAME_MESSAGE + ))); + } + + let key = helper.key().get_existing_key(&la.access_key_id).await?; + let state = key.state.as_option().unwrap(); + if matches!(state.local_aliases.get(&la.alias), Some(_)) { + return Err(Error::bad_request("Local alias already exists")); + } } - let key = helper.key().get_existing_key(&la.access_key_id).await?; - let state = key.state.as_option().unwrap(); - if matches!(state.local_aliases.get(&la.alias), Some(_)) { - return Err(Error::bad_request("Local alias already exists")); + let bucket = Bucket::new(); + garage.bucket_table.insert(&bucket).await?; + + if let Some(ga) = &self.global_alias { + helper.set_global_bucket_alias(bucket.id, ga).await?; } + + if let Some(la) = &self.local_alias { + helper + .set_local_bucket_alias(bucket.id, &la.access_key_id, &la.alias) + .await?; + + if la.allow.read || la.allow.write || la.allow.owner { + helper + .set_bucket_key_permissions( + bucket.id, + &la.access_key_id, + BucketKeyPerm { + timestamp: now_msec(), + allow_read: la.allow.read, + allow_write: la.allow.write, + allow_owner: la.allow.owner, + }, + ) + .await?; + } + } + + Ok(CreateBucketResponse( + bucket_info_results(garage, bucket.id).await?, + )) } +} - let bucket = Bucket::new(); - garage.bucket_table.insert(&bucket).await?; +#[async_trait] +impl EndpointHandler for DeleteBucketRequest { + type Response = DeleteBucketResponse; - if let Some(ga) = &req.global_alias { - helper.set_global_bucket_alias(bucket.id, ga).await?; + async fn handle(self, garage: &Arc) -> Result { + let helper = garage.locked_helper().await; + + let bucket_id = parse_bucket_id(&self.id)?; + + let mut bucket = helper.bucket().get_existing_bucket(bucket_id).await?; + let state = bucket.state.as_option().unwrap(); + + // Check bucket is empty + if !helper.bucket().is_bucket_empty(bucket_id).await? { + return Err(CommonError::BucketNotEmpty.into()); + } + + // --- done checking, now commit --- + // 1. delete authorization from keys that had access + for (key_id, perm) in bucket.authorized_keys() { + if perm.is_any() { + helper + .set_bucket_key_permissions(bucket.id, key_id, BucketKeyPerm::NO_PERMISSIONS) + .await?; + } + } + // 2. delete all local aliases + for ((key_id, alias), _, active) in state.local_aliases.items().iter() { + if *active { + helper + .unset_local_bucket_alias(bucket.id, key_id, alias) + .await?; + } + } + // 3. delete all global aliases + for (alias, _, active) in state.aliases.items().iter() { + if *active { + helper.purge_global_bucket_alias(bucket.id, alias).await?; + } + } + + // 4. delete bucket + bucket.state = Deletable::delete(); + garage.bucket_table.insert(&bucket).await?; + + Ok(DeleteBucketResponse) } +} - if let Some(la) = &req.local_alias { - helper - .set_local_bucket_alias(bucket.id, &la.access_key_id, &la.alias) +#[async_trait] +impl EndpointHandler for UpdateBucketRequest { + type Response = UpdateBucketResponse; + + async fn handle(self, garage: &Arc) -> Result { + let bucket_id = parse_bucket_id(&self.id)?; + + let mut bucket = garage + .bucket_helper() + .get_existing_bucket(bucket_id) .await?; - if la.allow.read || la.allow.write || la.allow.owner { - helper - .set_bucket_key_permissions( - bucket.id, - &la.access_key_id, - BucketKeyPerm { - timestamp: now_msec(), - allow_read: la.allow.read, - allow_write: la.allow.write, - allow_owner: la.allow.owner, - }, - ) - .await?; - } - } + let state = bucket.state.as_option_mut().unwrap(); - bucket_info_results(garage, bucket.id).await -} - -pub async fn handle_delete_bucket( - garage: &Arc, - id: String, -) -> Result, Error> { - let helper = garage.locked_helper().await; - - let bucket_id = parse_bucket_id(&id)?; - - let mut bucket = helper.bucket().get_existing_bucket(bucket_id).await?; - let state = bucket.state.as_option().unwrap(); - - // Check bucket is empty - if !helper.bucket().is_bucket_empty(bucket_id).await? { - return Err(CommonError::BucketNotEmpty.into()); - } - - // --- done checking, now commit --- - // 1. delete authorization from keys that had access - for (key_id, perm) in bucket.authorized_keys() { - if perm.is_any() { - helper - .set_bucket_key_permissions(bucket.id, key_id, BucketKeyPerm::NO_PERMISSIONS) - .await?; - } - } - // 2. delete all local aliases - for ((key_id, alias), _, active) in state.local_aliases.items().iter() { - if *active { - helper - .unset_local_bucket_alias(bucket.id, key_id, alias) - .await?; - } - } - // 3. delete all global aliases - for (alias, _, active) in state.aliases.items().iter() { - if *active { - helper.purge_global_bucket_alias(bucket.id, alias).await?; - } - } - - // 4. delete bucket - bucket.state = Deletable::delete(); - garage.bucket_table.insert(&bucket).await?; - - Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(empty_body())?) -} - -pub async fn handle_update_bucket( - garage: &Arc, - id: String, - req: Request, -) -> Result, Error> { - let req = parse_json_body::(req).await?; - let bucket_id = parse_bucket_id(&id)?; - - let mut bucket = garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - - let state = bucket.state.as_option_mut().unwrap(); - - if let Some(wa) = req.website_access { - if wa.enabled { - state.website_config.update(Some(WebsiteConfig { - index_document: wa.index_document.ok_or_bad_request( - "Please specify indexDocument when enabling website access.", - )?, - error_document: wa.error_document, - })); - } else { - if wa.index_document.is_some() || wa.error_document.is_some() { - return Err(Error::bad_request( - "Cannot specify indexDocument or errorDocument when disabling website access.", - )); + if let Some(wa) = self.params.website_access { + if wa.enabled { + state.website_config.update(Some(WebsiteConfig { + index_document: wa.index_document.ok_or_bad_request( + "Please specify indexDocument when enabling website access.", + )?, + error_document: wa.error_document, + })); + } else { + if wa.index_document.is_some() || wa.error_document.is_some() { + return Err(Error::bad_request( + "Cannot specify indexDocument or errorDocument when disabling website access.", + )); + } + state.website_config.update(None); } - state.website_config.update(None); } + + if let Some(q) = self.params.quotas { + state.quotas.update(BucketQuotas { + max_size: q.max_size, + max_objects: q.max_objects, + }); + } + + garage.bucket_table.insert(&bucket).await?; + + Ok(UpdateBucketResponse( + bucket_info_results(garage, bucket_id).await?, + )) } - - if let Some(q) = req.quotas { - state.quotas.update(BucketQuotas { - max_size: q.max_size, - max_objects: q.max_objects, - }); - } - - garage.bucket_table.insert(&bucket).await?; - - bucket_info_results(garage, bucket_id).await } // ---- BUCKET/KEY PERMISSIONS ---- +#[async_trait] +impl EndpointHandler for BucketAllowKeyRequest { + type Response = BucketAllowKeyResponse; + + async fn handle(self, garage: &Arc) -> Result { + let res = handle_bucket_change_key_perm(garage, self.0, true).await?; + Ok(BucketAllowKeyResponse(res)) + } +} + +#[async_trait] +impl EndpointHandler for BucketDenyKeyRequest { + type Response = BucketDenyKeyResponse; + + async fn handle(self, garage: &Arc) -> Result { + let res = handle_bucket_change_key_perm(garage, self.0, false).await?; + Ok(BucketDenyKeyResponse(res)) + } +} + pub async fn handle_bucket_change_key_perm( garage: &Arc, - req: Request, + req: BucketKeyPermChangeRequest, new_perm_flag: bool, -) -> Result, Error> { - let req = parse_json_body::(req).await?; - +) -> Result { let helper = garage.locked_helper().await; let bucket_id = parse_bucket_id(&req.bucket_id)?; @@ -420,66 +452,80 @@ pub async fn handle_bucket_change_key_perm( // ---- BUCKET ALIASES ---- -pub async fn handle_global_alias_bucket( - garage: &Arc, - bucket_id: String, - alias: String, -) -> Result, Error> { - let bucket_id = parse_bucket_id(&bucket_id)?; +#[async_trait] +impl EndpointHandler for GlobalAliasBucketRequest { + type Response = GlobalAliasBucketResponse; - let helper = garage.locked_helper().await; + async fn handle(self, garage: &Arc) -> Result { + let bucket_id = parse_bucket_id(&self.id)?; - helper.set_global_bucket_alias(bucket_id, &alias).await?; + let helper = garage.locked_helper().await; - bucket_info_results(garage, bucket_id).await + helper + .set_global_bucket_alias(bucket_id, &self.alias) + .await?; + + Ok(GlobalAliasBucketResponse( + bucket_info_results(garage, bucket_id).await?, + )) + } } -pub async fn handle_global_unalias_bucket( - garage: &Arc, - bucket_id: String, - alias: String, -) -> Result, Error> { - let bucket_id = parse_bucket_id(&bucket_id)?; +#[async_trait] +impl EndpointHandler for GlobalUnaliasBucketRequest { + type Response = GlobalUnaliasBucketResponse; - let helper = garage.locked_helper().await; + async fn handle(self, garage: &Arc) -> Result { + let bucket_id = parse_bucket_id(&self.id)?; - helper.unset_global_bucket_alias(bucket_id, &alias).await?; + let helper = garage.locked_helper().await; - bucket_info_results(garage, bucket_id).await + helper + .unset_global_bucket_alias(bucket_id, &self.alias) + .await?; + + Ok(GlobalUnaliasBucketResponse( + bucket_info_results(garage, bucket_id).await?, + )) + } } -pub async fn handle_local_alias_bucket( - garage: &Arc, - bucket_id: String, - access_key_id: String, - alias: String, -) -> Result, Error> { - let bucket_id = parse_bucket_id(&bucket_id)?; +#[async_trait] +impl EndpointHandler for LocalAliasBucketRequest { + type Response = LocalAliasBucketResponse; - let helper = garage.locked_helper().await; + async fn handle(self, garage: &Arc) -> Result { + let bucket_id = parse_bucket_id(&self.id)?; - helper - .set_local_bucket_alias(bucket_id, &access_key_id, &alias) - .await?; + let helper = garage.locked_helper().await; - bucket_info_results(garage, bucket_id).await + helper + .set_local_bucket_alias(bucket_id, &self.access_key_id, &self.alias) + .await?; + + Ok(LocalAliasBucketResponse( + bucket_info_results(garage, bucket_id).await?, + )) + } } -pub async fn handle_local_unalias_bucket( - garage: &Arc, - bucket_id: String, - access_key_id: String, - alias: String, -) -> Result, Error> { - let bucket_id = parse_bucket_id(&bucket_id)?; +#[async_trait] +impl EndpointHandler for LocalUnaliasBucketRequest { + type Response = LocalUnaliasBucketResponse; - let helper = garage.locked_helper().await; + async fn handle(self, garage: &Arc) -> Result { + let bucket_id = parse_bucket_id(&self.id)?; - helper - .unset_local_bucket_alias(bucket_id, &access_key_id, &alias) - .await?; + let helper = garage.locked_helper().await; - bucket_info_results(garage, bucket_id).await + helper + .unset_local_bucket_alias(bucket_id, &self.access_key_id, &self.alias) + .await?; + + Ok(LocalUnaliasBucketResponse( + bucket_info_results(garage, bucket_id).await?, + )) + } } // ---- HELPER ---- diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 11753509..c7eb7e7d 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; -use hyper::{body::Incoming as IncomingBody, Request, Response}; use garage_util::crdt::*; use garage_util::data::*; @@ -14,14 +13,13 @@ use garage_model::garage::Garage; use crate::admin::api::{ ApplyClusterLayoutRequest, ApplyClusterLayoutResponse, ConnectClusterNodeResponse, ConnectClusterNodesRequest, ConnectClusterNodesResponse, FreeSpaceResp, - GetClusterHealthRequest, GetClusterHealthResponse, GetClusterLayoutResponse, - GetClusterStatusRequest, GetClusterStatusResponse, NodeResp, NodeRoleChange, - NodeRoleChangeEnum, NodeRoleResp, UpdateClusterLayoutRequest, + GetClusterHealthRequest, GetClusterHealthResponse, GetClusterLayoutRequest, + GetClusterLayoutResponse, GetClusterStatusRequest, GetClusterStatusResponse, NodeResp, + NodeRoleChange, NodeRoleChangeEnum, NodeRoleResp, RevertClusterLayoutRequest, + RevertClusterLayoutResponse, UpdateClusterLayoutRequest, UpdateClusterLayoutResponse, }; -use crate::admin::api_server::ResBody; use crate::admin::error::*; use crate::admin::EndpointHandler; -use crate::helpers::{json_ok_response, parse_json_body}; #[async_trait] impl EndpointHandler for GetClusterStatusRequest { @@ -149,17 +147,6 @@ impl EndpointHandler for GetClusterHealthRequest { } } -pub async fn handle_connect_cluster_nodes( - garage: &Arc, - req: Request, -) -> Result, Error> { - let req = parse_json_body::(req).await?; - - let res = req.handle(garage).await?; - - Ok(json_ok_response(&res)?) -} - #[async_trait] impl EndpointHandler for ConnectClusterNodesRequest { type Response = ConnectClusterNodesResponse; @@ -183,10 +170,15 @@ impl EndpointHandler for ConnectClusterNodesRequest { } } -pub async fn handle_get_cluster_layout(garage: &Arc) -> Result, Error> { - let res = format_cluster_layout(garage.system.cluster_layout().inner()); +#[async_trait] +impl EndpointHandler for GetClusterLayoutRequest { + type Response = GetClusterLayoutResponse; - Ok(json_ok_response(&res)?) + async fn handle(self, garage: &Arc) -> Result { + Ok(format_cluster_layout( + garage.system.cluster_layout().inner(), + )) + } } fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResponse { @@ -238,85 +230,87 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp // ---- update functions ---- -pub async fn handle_update_cluster_layout( - garage: &Arc, - req: Request, -) -> Result, Error> { - let updates = parse_json_body::(req).await?; +#[async_trait] +impl EndpointHandler for UpdateClusterLayoutRequest { + type Response = UpdateClusterLayoutResponse; - let mut layout = garage.system.cluster_layout().inner().clone(); + async fn handle(self, garage: &Arc) -> Result { + let mut layout = garage.system.cluster_layout().inner().clone(); - let mut roles = layout.current().roles.clone(); - roles.merge(&layout.staging.get().roles); + let mut roles = layout.current().roles.clone(); + roles.merge(&layout.staging.get().roles); - for change in updates.0 { - let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?; - let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?; + for change in self.0 { + let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?; + let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?; - let new_role = match change.action { - NodeRoleChangeEnum::Remove { remove: true } => None, - NodeRoleChangeEnum::Update { - zone, - capacity, - tags, - } => Some(layout::NodeRole { - zone, - capacity, - tags, - }), - _ => return Err(Error::bad_request("Invalid layout change")), - }; + let new_role = match change.action { + NodeRoleChangeEnum::Remove { remove: true } => None, + NodeRoleChangeEnum::Update { + zone, + capacity, + tags, + } => Some(layout::NodeRole { + zone, + capacity, + tags, + }), + _ => return Err(Error::bad_request("Invalid layout change")), + }; - layout - .staging - .get_mut() - .roles - .merge(&roles.update_mutator(node, layout::NodeRoleV(new_role))); + layout + .staging + .get_mut() + .roles + .merge(&roles.update_mutator(node, layout::NodeRoleV(new_role))); + } + + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + let res = format_cluster_layout(&layout); + Ok(UpdateClusterLayoutResponse(res)) } - - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; - - let res = format_cluster_layout(&layout); - Ok(json_ok_response(&res)?) } -pub async fn handle_apply_cluster_layout( - garage: &Arc, - req: Request, -) -> Result, Error> { - let param = parse_json_body::(req).await?; +#[async_trait] +impl EndpointHandler for ApplyClusterLayoutRequest { + type Response = ApplyClusterLayoutResponse; - let layout = garage.system.cluster_layout().inner().clone(); - let (layout, msg) = layout.apply_staged_changes(Some(param.version))?; + async fn handle(self, garage: &Arc) -> Result { + let layout = garage.system.cluster_layout().inner().clone(); + let (layout, msg) = layout.apply_staged_changes(Some(self.version))?; - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; - let res = ApplyClusterLayoutResponse { - message: msg, - layout: format_cluster_layout(&layout), - }; - Ok(json_ok_response(&res)?) + Ok(ApplyClusterLayoutResponse { + message: msg, + layout: format_cluster_layout(&layout), + }) + } } -pub async fn handle_revert_cluster_layout( - garage: &Arc, -) -> Result, Error> { - let layout = garage.system.cluster_layout().inner().clone(); - let layout = layout.revert_staged_changes()?; - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; +#[async_trait] +impl EndpointHandler for RevertClusterLayoutRequest { + type Response = RevertClusterLayoutResponse; - let res = format_cluster_layout(&layout); - Ok(json_ok_response(&res)?) + async fn handle(self, garage: &Arc) -> Result { + let layout = garage.system.cluster_layout().inner().clone(); + let layout = layout.revert_staged_changes()?; + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + let res = format_cluster_layout(&layout); + Ok(RevertClusterLayoutResponse(res)) + } } diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 96ce3518..8161672f 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; +use async_trait::async_trait; use garage_table::*; @@ -9,138 +9,149 @@ use garage_model::garage::Garage; use garage_model::key_table::*; use crate::admin::api::{ - ApiBucketKeyPerm, CreateKeyRequest, GetKeyInfoResponse, ImportKeyRequest, - KeyInfoBucketResponse, KeyPerm, ListKeysResponseItem, UpdateKeyRequest, + ApiBucketKeyPerm, CreateKeyRequest, CreateKeyResponse, DeleteKeyRequest, DeleteKeyResponse, + GetKeyInfoRequest, GetKeyInfoResponse, ImportKeyRequest, ImportKeyResponse, + KeyInfoBucketResponse, KeyPerm, ListKeysRequest, ListKeysResponse, ListKeysResponseItem, + UpdateKeyRequest, UpdateKeyResponse, }; -use crate::admin::api_server::ResBody; use crate::admin::error::*; -use crate::helpers::*; +use crate::admin::EndpointHandler; -pub async fn handle_list_keys(garage: &Arc) -> Result, Error> { - let res = garage - .key_table - .get_range( - &EmptyKey, - None, - Some(KeyFilter::Deleted(DeletedFilter::NotDeleted)), - 10000, - EnumerationOrder::Forward, - ) - .await? - .iter() - .map(|k| ListKeysResponseItem { - id: k.key_id.to_string(), - name: k.params().unwrap().name.get().clone(), - }) - .collect::>(); +#[async_trait] +impl EndpointHandler for ListKeysRequest { + type Response = ListKeysResponse; - Ok(json_ok_response(&res)?) -} - -pub async fn handle_get_key_info( - garage: &Arc, - id: Option, - search: Option, - show_secret_key: bool, -) -> Result, Error> { - let key = if let Some(id) = id { - garage.key_helper().get_existing_key(&id).await? - } else if let Some(search) = search { - garage - .key_helper() - .get_existing_matching_key(&search) + async fn handle(self, garage: &Arc) -> Result { + let res = garage + .key_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::Deleted(DeletedFilter::NotDeleted)), + 10000, + EnumerationOrder::Forward, + ) .await? - } else { - unreachable!(); - }; + .iter() + .map(|k| ListKeysResponseItem { + id: k.key_id.to_string(), + name: k.params().unwrap().name.get().clone(), + }) + .collect::>(); - key_info_results(garage, key, show_secret_key).await -} - -pub async fn handle_create_key( - garage: &Arc, - req: Request, -) -> Result, Error> { - let req = parse_json_body::(req).await?; - - let key = Key::new(req.name.as_deref().unwrap_or("Unnamed key")); - garage.key_table.insert(&key).await?; - - key_info_results(garage, key, true).await -} - -pub async fn handle_import_key( - garage: &Arc, - req: Request, -) -> Result, Error> { - let req = parse_json_body::(req).await?; - - let prev_key = garage.key_table.get(&EmptyKey, &req.access_key_id).await?; - if prev_key.is_some() { - return Err(Error::KeyAlreadyExists(req.access_key_id.to_string())); + Ok(ListKeysResponse(res)) } - - let imported_key = Key::import( - &req.access_key_id, - &req.secret_access_key, - req.name.as_deref().unwrap_or("Imported key"), - ) - .ok_or_bad_request("Invalid key format")?; - garage.key_table.insert(&imported_key).await?; - - key_info_results(garage, imported_key, false).await } -pub async fn handle_update_key( - garage: &Arc, - id: String, - req: Request, -) -> Result, Error> { - let req = parse_json_body::(req).await?; +#[async_trait] +impl EndpointHandler for GetKeyInfoRequest { + type Response = GetKeyInfoResponse; - let mut key = garage.key_helper().get_existing_key(&id).await?; + async fn handle(self, garage: &Arc) -> Result { + let key = if let Some(id) = self.id { + garage.key_helper().get_existing_key(&id).await? + } else if let Some(search) = self.search { + garage + .key_helper() + .get_existing_matching_key(&search) + .await? + } else { + unreachable!(); + }; - let key_state = key.state.as_option_mut().unwrap(); - - if let Some(new_name) = req.name { - key_state.name.update(new_name); + Ok(key_info_results(garage, key, self.show_secret_key).await?) } - if let Some(allow) = req.allow { - if allow.create_bucket { - key_state.allow_create_bucket.update(true); +} + +#[async_trait] +impl EndpointHandler for CreateKeyRequest { + type Response = CreateKeyResponse; + + async fn handle(self, garage: &Arc) -> Result { + let key = Key::new(self.name.as_deref().unwrap_or("Unnamed key")); + garage.key_table.insert(&key).await?; + + Ok(CreateKeyResponse( + key_info_results(garage, key, true).await?, + )) + } +} + +#[async_trait] +impl EndpointHandler for ImportKeyRequest { + type Response = ImportKeyResponse; + + async fn handle(self, garage: &Arc) -> Result { + let prev_key = garage.key_table.get(&EmptyKey, &self.access_key_id).await?; + if prev_key.is_some() { + return Err(Error::KeyAlreadyExists(self.access_key_id.to_string())); } - } - if let Some(deny) = req.deny { - if deny.create_bucket { - key_state.allow_create_bucket.update(false); - } - } - garage.key_table.insert(&key).await?; + let imported_key = Key::import( + &self.access_key_id, + &self.secret_access_key, + self.name.as_deref().unwrap_or("Imported key"), + ) + .ok_or_bad_request("Invalid key format")?; + garage.key_table.insert(&imported_key).await?; - key_info_results(garage, key, false).await + Ok(ImportKeyResponse( + key_info_results(garage, imported_key, false).await?, + )) + } } -pub async fn handle_delete_key( - garage: &Arc, - id: String, -) -> Result, Error> { - let helper = garage.locked_helper().await; +#[async_trait] +impl EndpointHandler for UpdateKeyRequest { + type Response = UpdateKeyResponse; - let mut key = helper.key().get_existing_key(&id).await?; + async fn handle(self, garage: &Arc) -> Result { + let mut key = garage.key_helper().get_existing_key(&self.id).await?; - helper.delete_key(&mut key).await?; + let key_state = key.state.as_option_mut().unwrap(); - Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(empty_body())?) + if let Some(new_name) = self.params.name { + key_state.name.update(new_name); + } + if let Some(allow) = self.params.allow { + if allow.create_bucket { + key_state.allow_create_bucket.update(true); + } + } + if let Some(deny) = self.params.deny { + if deny.create_bucket { + key_state.allow_create_bucket.update(false); + } + } + + garage.key_table.insert(&key).await?; + + Ok(UpdateKeyResponse( + key_info_results(garage, key, false).await?, + )) + } +} + +#[async_trait] +impl EndpointHandler for DeleteKeyRequest { + type Response = DeleteKeyResponse; + + async fn handle(self, garage: &Arc) -> Result { + let helper = garage.locked_helper().await; + + let mut key = helper.key().get_existing_key(&self.id).await?; + + helper.delete_key(&mut key).await?; + + Ok(DeleteKeyResponse) + } } async fn key_info_results( garage: &Arc, key: Key, show_secret: bool, -) -> Result, Error> { +) -> Result { let mut relevant_buckets = HashMap::new(); let key_state = key.state.as_option().unwrap(); @@ -211,5 +222,5 @@ async fn key_info_results( .collect::>(), }; - Ok(json_ok_response(&res)?) + Ok(res) } From c99bfe69ea19497895d32669fd15c689b86035d8 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 15:12:03 +0100 Subject: [PATCH 014/192] admin api: new router_v2 with unified path syntax --- Cargo.lock | 1 + Cargo.nix | 3 +- Cargo.toml | 1 + src/api/Cargo.toml | 1 + src/api/admin/api.rs | 31 ++-- src/api/admin/api_server.rs | 296 +++++------------------------------- src/api/admin/bucket.rs | 4 +- src/api/admin/key.rs | 6 +- src/api/admin/mod.rs | 11 +- src/api/admin/router_v1.rs | 7 +- src/api/admin/router_v2.rs | 169 ++++++++++++++++++++ src/api/admin/special.rs | 129 ++++++++++++++++ src/api/generic_server.rs | 3 +- src/api/k2v/api_server.rs | 5 +- src/api/router_macros.rs | 71 +++++++++ src/api/s3/api_server.rs | 5 +- 16 files changed, 451 insertions(+), 292 deletions(-) create mode 100644 src/api/admin/router_v2.rs create mode 100644 src/api/admin/special.rs diff --git a/Cargo.lock b/Cargo.lock index 0d3f70f0..ac39cbd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1402,6 +1402,7 @@ dependencies = [ "nom", "opentelemetry", "opentelemetry-prometheus", + "paste", "percent-encoding", "pin-project", "prometheus", diff --git a/Cargo.nix b/Cargo.nix index addc7629..fc6062f5 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -35,7 +35,7 @@ args@{ ignoreLockHash, }: let - nixifiedLockHash = "d13a40f6a67a6a1075dbb5a948d7bfceea51958a0b5b6182ad56a9e39ab4dfd0"; + nixifiedLockHash = "cc8c069ebe713e8225c166aa2bba5cc6e5016f007c6e7b7af36dd49452c859cc"; workspaceSrc = if args.workspaceSrc == null then ./. else args.workspaceSrc; currentLockHash = builtins.hashFile "sha256" (workspaceSrc + /Cargo.lock); lockHashIgnored = if ignoreLockHash @@ -2042,6 +2042,7 @@ in nom = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".nom."7.1.3" { inherit profileName; }).out; opentelemetry = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".opentelemetry."0.17.0" { inherit profileName; }).out; ${ if rootFeatures' ? "garage/default" || rootFeatures' ? "garage/metrics" || rootFeatures' ? "garage_api/metrics" || rootFeatures' ? "garage_api/opentelemetry-prometheus" then "opentelemetry_prometheus" else null } = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".opentelemetry-prometheus."0.10.0" { inherit profileName; }).out; + paste = (buildRustPackages."registry+https://github.com/rust-lang/crates.io-index".paste."1.0.14" { profileName = "__noProfile"; }).out; percent_encoding = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".percent-encoding."2.3.1" { inherit profileName; }).out; pin_project = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".pin-project."1.1.4" { inherit profileName; }).out; ${ if rootFeatures' ? "garage/default" || rootFeatures' ? "garage/metrics" || rootFeatures' ? "garage_api/metrics" || rootFeatures' ? "garage_api/prometheus" then "prometheus" else null } = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".prometheus."0.13.3" { inherit profileName; }).out; diff --git a/Cargo.toml b/Cargo.toml index 5ff0ec42..65e08f58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ mktemp = "0.5" nix = { version = "0.29", default-features = false, features = ["fs"] } nom = "7.1" parse_duration = "2.1" +paste = "1.0" pin-project = "1.0.12" pnet_datalink = "0.34" rand = "0.8" diff --git a/src/api/Cargo.toml b/src/api/Cargo.toml index 85b78a5b..1becbcdf 100644 --- a/src/api/Cargo.toml +++ b/src/api/Cargo.toml @@ -38,6 +38,7 @@ idna.workspace = true tracing.workspace = true md-5.workspace = true nom.workspace = true +paste.workspace = true pin-project.workspace = true sha1.workspace = true sha2.workspace = true diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index a5dbdfbe..b0ab058a 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -11,6 +11,12 @@ use crate::admin::EndpointHandler; use crate::helpers::is_default; pub enum AdminApiRequest { + // Special endpoints of the Admin API + Options(OptionsRequest), + CheckDomain(CheckDomainRequest), + Health(HealthRequest), + Metrics(MetricsRequest), + // Cluster operations GetClusterStatus(GetClusterStatusRequest), GetClusterHealth(GetClusterHealthRequest), @@ -90,6 +96,7 @@ impl EndpointHandler for AdminApiRequest { async fn handle(self, garage: &Arc) -> Result { Ok(match self { + Self::Options | Self::CheckDomain | Self::Health | Self::Metrics => unreachable!(), // Cluster operations Self::GetClusterStatus(req) => { AdminApiResponse::GetClusterStatus(req.handle(garage).await?) @@ -152,19 +159,19 @@ impl EndpointHandler for AdminApiRequest { } // ********************************************** -// Metrics-related endpoints +// Special endpoints // ********************************************** -// TODO: do we want this here ?? +pub struct OptionsRequest; -// ---- Metrics ---- - -pub struct MetricsRequest; - -// ---- Health ---- +pub struct CheckDomainRequest { + pub domain: String, +} pub struct HealthRequest; +pub struct MetricsRequest; + // ********************************************** // Cluster operations // ********************************************** @@ -404,7 +411,7 @@ pub struct ImportKeyResponse(pub GetKeyInfoResponse); pub struct UpdateKeyRequest { pub id: String, - pub params: UpdateKeyRequestParams, + pub body: UpdateKeyRequestBody, } #[derive(Serialize)] @@ -412,7 +419,7 @@ pub struct UpdateKeyResponse(pub GetKeyInfoResponse); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UpdateKeyRequestParams { +pub struct UpdateKeyRequestBody { // TODO: id (get parameter) goes here pub name: Option, pub allow: Option, @@ -527,7 +534,7 @@ pub struct CreateBucketLocalAlias { pub struct UpdateBucketRequest { pub id: String, - pub params: UpdateBucketRequestParams, + pub body: UpdateBucketRequestBody, } #[derive(Serialize)] @@ -535,7 +542,7 @@ pub struct UpdateBucketResponse(pub GetBucketInfoResponse); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UpdateBucketRequestParams { +pub struct UpdateBucketRequestBody { pub website_access: Option, pub quotas: Option, } @@ -563,6 +570,7 @@ pub struct DeleteBucketResponse; // ---- BucketAllowKey ---- +#[derive(Deserialize)] pub struct BucketAllowKeyRequest(pub BucketKeyPermChangeRequest); #[derive(Serialize)] @@ -578,6 +586,7 @@ pub struct BucketKeyPermChangeRequest { // ---- BucketDenyKey ---- +#[derive(Deserialize)] pub struct BucketDenyKeyRequest(pub BucketKeyPermChangeRequest); #[derive(Serialize)] diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index c6b7661c..b235dafc 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -1,10 +1,10 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; use argon2::password_hash::PasswordHash; use async_trait::async_trait; -use http::header::{ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ALLOW}; use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; use tokio::sync::watch; @@ -25,7 +25,7 @@ use crate::generic_server::*; use crate::admin::api::*; use crate::admin::error::*; use crate::admin::router_v0; -use crate::admin::router_v1::{Authorization, Endpoint}; +use crate::admin::router_v1; use crate::admin::EndpointHandler; use crate::helpers::*; @@ -39,6 +39,11 @@ pub struct AdminApiServer { admin_token: Option, } +enum Endpoint { + Old(endpoint_v1::Endpoint), + New(String), +} + impl AdminApiServer { pub fn new( garage: Arc, @@ -67,130 +72,6 @@ impl AdminApiServer { .await } - fn handle_options(&self, _req: &Request) -> Result, Error> { - Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .header(ALLOW, "OPTIONS, GET, POST") - .header(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS, GET, POST") - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(empty_body())?) - } - - async fn handle_check_domain( - &self, - req: Request, - ) -> Result, Error> { - let query_params: HashMap = req - .uri() - .query() - .map(|v| { - url::form_urlencoded::parse(v.as_bytes()) - .into_owned() - .collect() - }) - .unwrap_or_else(HashMap::new); - - let has_domain_key = query_params.contains_key("domain"); - - if !has_domain_key { - return Err(Error::bad_request("No domain query string found")); - } - - let domain = query_params - .get("domain") - .ok_or_internal_error("Could not parse domain query string")?; - - if self.check_domain(domain).await? { - Ok(Response::builder() - .status(StatusCode::OK) - .body(string_body(format!( - "Domain '{domain}' is managed by Garage" - )))?) - } else { - Err(Error::bad_request(format!( - "Domain '{domain}' is not managed by Garage" - ))) - } - } - - async fn check_domain(&self, domain: &str) -> Result { - // Resolve bucket from domain name, inferring if the website must be activated for the - // domain to be valid. - let (bucket_name, must_check_website) = if let Some(bname) = self - .garage - .config - .s3_api - .root_domain - .as_ref() - .and_then(|rd| host_to_bucket(domain, rd)) - { - (bname.to_string(), false) - } else if let Some(bname) = self - .garage - .config - .s3_web - .as_ref() - .and_then(|sw| host_to_bucket(domain, sw.root_domain.as_str())) - { - (bname.to_string(), true) - } else { - (domain.to_string(), true) - }; - - let bucket_id = match self - .garage - .bucket_helper() - .resolve_global_bucket_name(&bucket_name) - .await? - { - Some(bucket_id) => bucket_id, - None => return Ok(false), - }; - - if !must_check_website { - return Ok(true); - } - - let bucket = self - .garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - - let bucket_state = bucket.state.as_option().unwrap(); - let bucket_website_config = bucket_state.website_config.get(); - - match bucket_website_config { - Some(_v) => Ok(true), - None => Ok(false), - } - } - - fn handle_health(&self) -> Result, Error> { - let health = self.garage.system.health(); - - let (status, status_str) = match health.status { - ClusterHealthStatus::Healthy => (StatusCode::OK, "Garage is fully operational"), - ClusterHealthStatus::Degraded => ( - StatusCode::OK, - "Garage is operational but some storage nodes are unavailable", - ), - ClusterHealthStatus::Unavailable => ( - StatusCode::SERVICE_UNAVAILABLE, - "Quorum is not available for some/all partitions, reads and writes will fail", - ), - }; - let status_str = format!( - "{}\nConsult the full health check API endpoint at /v1/health for more details\n", - status_str - ); - - Ok(Response::builder() - .status(status) - .header(http::header::CONTENT_TYPE, "text/plain") - .body(string_body(status_str))?) - } - fn handle_metrics(&self) -> Result, Error> { #[cfg(feature = "metrics")] { @@ -231,9 +112,13 @@ impl ApiHandler for AdminApiServer { fn parse_endpoint(&self, req: &Request) -> Result { if req.uri().path().starts_with("/v0/") { let endpoint_v0 = router_v0::Endpoint::from_request(req)?; - Endpoint::from_v0(endpoint_v0) + let endpoint_v1 = router_v1::Endpoint::from_v0(endpoint_v0); + Ok(Endpoint::Old(endpoint_v1)) + } else if req.uri().path().starts_with("/v1/") { + let endpoint_v1 = router_v1::Endpoint::from_request(req)?; + Ok(Endpoint::Old(endpoint_v1)) } else { - Endpoint::from_request(req) + Ok(Endpoint::New(req.uri().path().to_string())) } } @@ -242,8 +127,15 @@ impl ApiHandler for AdminApiServer { req: Request, endpoint: Endpoint, ) -> Result, Error> { + let request = match endpoint { + Endpoint::Old(endpoint_v1) => { + todo!() // TODO: convert from old semantics, if possible + } + Endpoint::New(_) => AdminApiRequest::from_request(req).await?, + }; + let required_auth_hash = - match endpoint.authorization_type() { + match request.authorization_type() { Authorization::None => None, Authorization::MetricsToken => self.metrics_token.as_deref(), Authorization::AdminToken => match self.admin_token.as_deref() { @@ -263,145 +155,25 @@ impl ApiHandler for AdminApiServer { } } - match endpoint { - Endpoint::Options => self.handle_options(&req), - Endpoint::CheckDomain => self.handle_check_domain(req).await, - Endpoint::Health => self.handle_health(), - Endpoint::Metrics => self.handle_metrics(), - e => { - async { - let body = parse_request_body(e, req).await?; - let res = body.handle(&self.garage).await?; - json_ok_response(&res) - } - .await + match request { + AdminApiRequest::Options(req) => req.handle(&self.garage).await, + AdminApiRequest::CheckDomain(req) => req.handle(&self.garage).await, + AdminApiRequest::Health(req) => req.handle(&self.garage).await, + AdminApiRequest::Metrics(req) => self.handle_metrics(), + req => { + let res = req.handle(&self.garage).await?; + json_ok_response(&res) } } } } -async fn parse_request_body( - endpoint: Endpoint, - req: Request, -) -> Result { - match endpoint { - Endpoint::GetClusterStatus => { - Ok(AdminApiRequest::GetClusterStatus(GetClusterStatusRequest)) - } - Endpoint::GetClusterHealth => { - Ok(AdminApiRequest::GetClusterHealth(GetClusterHealthRequest)) - } - Endpoint::ConnectClusterNodes => { - let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::ConnectClusterNodes(req)) - } - // Layout - Endpoint::GetClusterLayout => { - Ok(AdminApiRequest::GetClusterLayout(GetClusterLayoutRequest)) - } - Endpoint::UpdateClusterLayout => { - let updates = parse_json_body::(req).await?; - Ok(AdminApiRequest::UpdateClusterLayout(updates)) - } - Endpoint::ApplyClusterLayout => { - let param = parse_json_body::(req).await?; - Ok(AdminApiRequest::ApplyClusterLayout(param)) - } - Endpoint::RevertClusterLayout => Ok(AdminApiRequest::RevertClusterLayout( - RevertClusterLayoutRequest, - )), - // Keys - Endpoint::ListKeys => Ok(AdminApiRequest::ListKeys(ListKeysRequest)), - Endpoint::GetKeyInfo { - id, - search, - show_secret_key, - } => { - let show_secret_key = show_secret_key.map(|x| x == "true").unwrap_or(false); - Ok(AdminApiRequest::GetKeyInfo(GetKeyInfoRequest { - id, - search, - show_secret_key, - })) - } - Endpoint::CreateKey => { - let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::CreateKey(req)) - } - Endpoint::ImportKey => { - let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::ImportKey(req)) - } - Endpoint::UpdateKey { id } => { - let params = parse_json_body::(req).await?; - Ok(AdminApiRequest::UpdateKey(UpdateKeyRequest { id, params })) - } - Endpoint::DeleteKey { id } => Ok(AdminApiRequest::DeleteKey(DeleteKeyRequest { id })), - // Buckets - Endpoint::ListBuckets => Ok(AdminApiRequest::ListBuckets(ListBucketsRequest)), - Endpoint::GetBucketInfo { id, global_alias } => { - Ok(AdminApiRequest::GetBucketInfo(GetBucketInfoRequest { - id, - global_alias, - })) - } - Endpoint::CreateBucket => { - let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::CreateBucket(req)) - } - Endpoint::DeleteBucket { id } => { - Ok(AdminApiRequest::DeleteBucket(DeleteBucketRequest { id })) - } - Endpoint::UpdateBucket { id } => { - let params = parse_json_body::(req).await?; - Ok(AdminApiRequest::UpdateBucket(UpdateBucketRequest { - id, - params, - })) - } - // Bucket-key permissions - Endpoint::BucketAllowKey => { - let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::BucketAllowKey(BucketAllowKeyRequest(req))) - } - Endpoint::BucketDenyKey => { - let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::BucketDenyKey(BucketDenyKeyRequest(req))) - } - // Bucket aliasing - Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::GlobalAliasBucket( - GlobalAliasBucketRequest { id, alias }, - )), - Endpoint::GlobalUnaliasBucket { id, alias } => Ok(AdminApiRequest::GlobalUnaliasBucket( - GlobalUnaliasBucketRequest { id, alias }, - )), - Endpoint::LocalAliasBucket { - id, - access_key_id, - alias, - } => Ok(AdminApiRequest::LocalAliasBucket(LocalAliasBucketRequest { - access_key_id, - id, - alias, - })), - Endpoint::LocalUnaliasBucket { - id, - access_key_id, - alias, - } => Ok(AdminApiRequest::LocalUnaliasBucket( - LocalUnaliasBucketRequest { - access_key_id, - id, - alias, - }, - )), - _ => unreachable!(), - } -} - impl ApiEndpoint for Endpoint { - fn name(&self) -> &'static str { - Endpoint::name(self) + fn name(&self) -> Cow<'_, str> { + match self { + Self::Old(endpoint_v1) => Cow::owned(format!("v1:{}", endpoint_v1.name)), + Self::New(path) => Cow::borrowed(&path), + } } fn add_span_attributes(&self, _span: SpanRef<'_>) {} diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index d62bfa54..f9accba5 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -358,7 +358,7 @@ impl EndpointHandler for UpdateBucketRequest { let state = bucket.state.as_option_mut().unwrap(); - if let Some(wa) = self.params.website_access { + if let Some(wa) = self.body.website_access { if wa.enabled { state.website_config.update(Some(WebsiteConfig { index_document: wa.index_document.ok_or_bad_request( @@ -376,7 +376,7 @@ impl EndpointHandler for UpdateBucketRequest { } } - if let Some(q) = self.params.quotas { + if let Some(q) = self.body.quotas { state.quotas.update(BucketQuotas { max_size: q.max_size, max_objects: q.max_objects, diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 8161672f..5bec2202 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -110,15 +110,15 @@ impl EndpointHandler for UpdateKeyRequest { let key_state = key.state.as_option_mut().unwrap(); - if let Some(new_name) = self.params.name { + if let Some(new_name) = self.body.name { key_state.name.update(new_name); } - if let Some(allow) = self.params.allow { + if let Some(allow) = self.body.allow { if allow.create_bucket { key_state.allow_create_bucket.update(true); } } - if let Some(deny) = self.params.deny { + if let Some(deny) = self.body.deny { if deny.create_bucket { key_state.allow_create_bucket.update(false); } diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index e64eca7e..f4c37298 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -4,21 +4,28 @@ mod error; pub mod api; mod router_v0; mod router_v1; +mod router_v2; mod bucket; mod cluster; mod key; +mod special; use std::sync::Arc; use async_trait::async_trait; -use serde::Serialize; use garage_model::garage::Garage; +pub enum Authorization { + None, + MetricsToken, + AdminToken, +} + #[async_trait] pub trait EndpointHandler { - type Response: Serialize; + type Response; async fn handle(self, garage: &Arc) -> Result; } diff --git a/src/api/admin/router_v1.rs b/src/api/admin/router_v1.rs index cc5ff2ec..d69675cc 100644 --- a/src/api/admin/router_v1.rs +++ b/src/api/admin/router_v1.rs @@ -4,14 +4,9 @@ use hyper::{Method, Request}; use crate::admin::error::*; use crate::admin::router_v0; +use crate::admin::Authorization; use crate::router_macros::*; -pub enum Authorization { - None, - MetricsToken, - AdminToken, -} - router_match! {@func /// List of all Admin API endpoints. diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs new file mode 100644 index 00000000..9d203500 --- /dev/null +++ b/src/api/admin/router_v2.rs @@ -0,0 +1,169 @@ +use std::borrow::Cow; + +use hyper::body::Incoming as IncomingBody; +use hyper::{Method, Request}; +use paste::paste; + +use crate::admin::api::*; +use crate::admin::error::*; +//use crate::admin::router_v1; +use crate::admin::Authorization; +use crate::helpers::*; +use crate::router_macros::*; + +impl AdminApiRequest { + /// Determine which S3 endpoint a request is for using the request, and a bucket which was + /// possibly extracted from the Host header. + /// Returns Self plus bucket name, if endpoint is not Endpoint::ListBuckets + pub async fn from_request(req: Request) -> Result { + let uri = req.uri().clone(); + let path = uri.path(); + let query = uri.query(); + + let method = req.method().clone(); + + let mut query = QueryParameters::from_query(query.unwrap_or_default())?; + + let res = router_match!(@gen_path_parser_v2 (&method, path, "/v2/", query, req) [ + @special OPTIONS _ => Options (), + @special GET "/check" => CheckDomain (query::domain), + @special GET "/health" => Health (), + @special GET "/metrics" => Metrics (), + // Cluster endpoints + GET GetClusterStatus (), + GET GetClusterHealth (), + POST ConnectClusterNodes (body), + // Layout endpoints + GET GetClusterLayout (), + POST UpdateClusterLayout (body), + POST ApplyClusterLayout (body), + POST RevertClusterLayout (), + // API key endpoints + GET GetKeyInfo (query_opt::id, query_opt::search, parse_default(false)::show_secret_key), + POST UpdateKey (body_field, query::id), + POST CreateKey (body), + POST ImportKey (body), + DELETE DeleteKey (query::id), + GET ListKeys (), + // Bucket endpoints + GET GetBucketInfo (query_opt::id, query_opt::global_alias), + GET ListBuckets (), + POST CreateBucket (body), + DELETE DeleteBucket (query::id), + PUT UpdateBucket (body_field, query::id), + // Bucket-key permissions + POST BucketAllowKey (body), + POST BucketDenyKey (body), + // Bucket aliases + PUT GlobalAliasBucket (query::id, query::alias), + DELETE GlobalUnaliasBucket (query::id, query::alias), + PUT LocalAliasBucket (query::id, query::access_key_id, query::alias), + DELETE LocalUnaliasBucket (query::id, query::access_key_id, query::alias), + ]); + + if let Some(message) = query.nonempty_message() { + debug!("Unused query parameter: {}", message) + } + + Ok(res) + } + /* + /// Some endpoints work exactly the same in their v1/ version as they did in their v0/ version. + /// For these endpoints, we can convert a v0/ call to its equivalent as if it was made using + /// its v1/ URL. + pub fn from_v0(v0_endpoint: router_v0::Endpoint) -> Result { + match v0_endpoint { + // Cluster endpoints + router_v0::Endpoint::ConnectClusterNodes => Ok(Self::ConnectClusterNodes), + // - GetClusterStatus: response format changed + // - GetClusterHealth: response format changed + + // Layout endpoints + router_v0::Endpoint::RevertClusterLayout => Ok(Self::RevertClusterLayout), + // - GetClusterLayout: response format changed + // - UpdateClusterLayout: query format changed + // - ApplyCusterLayout: response format changed + + // Key endpoints + router_v0::Endpoint::ListKeys => Ok(Self::ListKeys), + router_v0::Endpoint::CreateKey => Ok(Self::CreateKey), + router_v0::Endpoint::GetKeyInfo { id, search } => Ok(Self::GetKeyInfo { + id, + search, + show_secret_key: Some("true".into()), + }), + router_v0::Endpoint::DeleteKey { id } => Ok(Self::DeleteKey { id }), + // - UpdateKey: response format changed (secret key no longer returned) + + // Bucket endpoints + router_v0::Endpoint::GetBucketInfo { id, global_alias } => { + Ok(Self::GetBucketInfo { id, global_alias }) + } + router_v0::Endpoint::ListBuckets => Ok(Self::ListBuckets), + router_v0::Endpoint::CreateBucket => Ok(Self::CreateBucket), + router_v0::Endpoint::DeleteBucket { id } => Ok(Self::DeleteBucket { id }), + router_v0::Endpoint::UpdateBucket { id } => Ok(Self::UpdateBucket { id }), + + // Bucket-key permissions + router_v0::Endpoint::BucketAllowKey => Ok(Self::BucketAllowKey), + router_v0::Endpoint::BucketDenyKey => Ok(Self::BucketDenyKey), + + // Bucket alias endpoints + router_v0::Endpoint::GlobalAliasBucket { id, alias } => { + Ok(Self::GlobalAliasBucket { id, alias }) + } + router_v0::Endpoint::GlobalUnaliasBucket { id, alias } => { + Ok(Self::GlobalUnaliasBucket { id, alias }) + } + router_v0::Endpoint::LocalAliasBucket { + id, + access_key_id, + alias, + } => Ok(Self::LocalAliasBucket { + id, + access_key_id, + alias, + }), + router_v0::Endpoint::LocalUnaliasBucket { + id, + access_key_id, + alias, + } => Ok(Self::LocalUnaliasBucket { + id, + access_key_id, + alias, + }), + + // For endpoints that have different body content syntax, issue + // deprecation warning + _ => Err(Error::bad_request(format!( + "v0/ endpoint is no longer supported: {}", + v0_endpoint.name() + ))), + } + } + */ + /// Get the kind of authorization which is required to perform the operation. + pub fn authorization_type(&self) -> Authorization { + match self { + Self::Health(_) => Authorization::None, + Self::CheckDomain(_) => Authorization::None, + Self::Metrics(_) => Authorization::MetricsToken, + _ => Authorization::AdminToken, + } + } +} + +generateQueryParameters! { + keywords: [], + fields: [ + "domain" => domain, + "format" => format, + "id" => id, + "search" => search, + "globalAlias" => global_alias, + "alias" => alias, + "accessKeyId" => access_key_id, + "showSecretKey" => show_secret_key + ] +} diff --git a/src/api/admin/special.rs b/src/api/admin/special.rs new file mode 100644 index 00000000..0239021a --- /dev/null +++ b/src/api/admin/special.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use http::header::{ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ALLOW}; +use hyper::{Response, StatusCode}; + +use garage_model::garage::Garage; +use garage_rpc::system::ClusterHealthStatus; + +use crate::admin::api::{CheckDomainRequest, HealthRequest, OptionsRequest}; +use crate::admin::api_server::ResBody; +use crate::admin::error::*; +use crate::admin::EndpointHandler; +use crate::helpers::*; + +#[async_trait] +impl EndpointHandler for OptionsRequest { + type Response = Response; + + async fn handle(self, _garage: &Arc) -> Result, Error> { + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header(ALLOW, "OPTIONS, GET, POST") + .header(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS, GET, POST") + .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .body(empty_body())?) + } +} + +#[async_trait] +impl EndpointHandler for CheckDomainRequest { + type Response = Response; + + async fn handle(self, garage: &Arc) -> Result, Error> { + if check_domain(garage, &self.domain).await? { + Ok(Response::builder() + .status(StatusCode::OK) + .body(string_body(format!( + "Domain '{}' is managed by Garage", + self.domain + )))?) + } else { + Err(Error::bad_request(format!( + "Domain '{}' is not managed by Garage", + self.domain + ))) + } + } +} + +async fn check_domain(garage: &Arc, domain: &str) -> Result { + // Resolve bucket from domain name, inferring if the website must be activated for the + // domain to be valid. + let (bucket_name, must_check_website) = if let Some(bname) = garage + .config + .s3_api + .root_domain + .as_ref() + .and_then(|rd| host_to_bucket(domain, rd)) + { + (bname.to_string(), false) + } else if let Some(bname) = garage + .config + .s3_web + .as_ref() + .and_then(|sw| host_to_bucket(domain, sw.root_domain.as_str())) + { + (bname.to_string(), true) + } else { + (domain.to_string(), true) + }; + + let bucket_id = match garage + .bucket_helper() + .resolve_global_bucket_name(&bucket_name) + .await? + { + Some(bucket_id) => bucket_id, + None => return Ok(false), + }; + + if !must_check_website { + return Ok(true); + } + + let bucket = garage + .bucket_helper() + .get_existing_bucket(bucket_id) + .await?; + + let bucket_state = bucket.state.as_option().unwrap(); + let bucket_website_config = bucket_state.website_config.get(); + + match bucket_website_config { + Some(_v) => Ok(true), + None => Ok(false), + } +} + +#[async_trait] +impl EndpointHandler for HealthRequest { + type Response = Response; + + async fn handle(self, garage: &Arc) -> Result, Error> { + let health = garage.system.health(); + + let (status, status_str) = match health.status { + ClusterHealthStatus::Healthy => (StatusCode::OK, "Garage is fully operational"), + ClusterHealthStatus::Degraded => ( + StatusCode::OK, + "Garage is operational but some storage nodes are unavailable", + ), + ClusterHealthStatus::Unavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "Quorum is not available for some/all partitions, reads and writes will fail", + ), + }; + let status_str = format!( + "{}\nConsult the full health check API endpoint at /v2/GetClusterHealth for more details\n", + status_str + ); + + Ok(Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(string_body(status_str))?) + } +} diff --git a/src/api/generic_server.rs b/src/api/generic_server.rs index 283abdd4..ce2ff7b7 100644 --- a/src/api/generic_server.rs +++ b/src/api/generic_server.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::convert::Infallible; use std::fs::{self, Permissions}; use std::os::unix::fs::PermissionsExt; @@ -37,7 +38,7 @@ use garage_util::socket_address::UnixOrTCPSocketAddress; use crate::helpers::{BoxBody, ErrorBody}; pub(crate) trait ApiEndpoint: Send + Sync + 'static { - fn name(&self) -> &'static str; + fn name(&self) -> Cow<'_, str>; fn add_span_attributes(&self, span: SpanRef<'_>); } diff --git a/src/api/k2v/api_server.rs b/src/api/k2v/api_server.rs index de6e5f06..35931914 100644 --- a/src/api/k2v/api_server.rs +++ b/src/api/k2v/api_server.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::sync::Arc; use async_trait::async_trait; @@ -181,8 +182,8 @@ impl ApiHandler for K2VApiServer { } impl ApiEndpoint for K2VApiEndpoint { - fn name(&self) -> &'static str { - self.endpoint.name() + fn name(&self) -> Cow<'_, str> { + Cow::borrowed(self.endpoint.name()) } fn add_span_attributes(&self, span: SpanRef<'_>) { diff --git a/src/api/router_macros.rs b/src/api/router_macros.rs index 8f10a4f5..acbe097c 100644 --- a/src/api/router_macros.rs +++ b/src/api/router_macros.rs @@ -44,6 +44,68 @@ macro_rules! router_match { } } }}; + (@gen_path_parser_v2 ($method:expr, $reqpath:expr, $pathprefix:literal, $query:expr, $req:expr) + [ + $(@special $spec_meth:ident $spec_path:pat => $spec_api:ident $spec_params:tt,)* + $($meth:ident $api:ident $params:tt,)* + ]) => {{ + { + #[allow(unused_parens)] + match ($method, $reqpath) { + $( + (&Method::$spec_meth, $spec_path) => AdminApiRequest::$spec_api ( + router_match!(@@gen_parse_request $spec_api, $spec_params, $query, $req) + ), + )* + $( + (&Method::$meth, concat!($pathprefix, stringify!($api))) + => AdminApiRequest::$api ( + router_match!(@@gen_parse_request $api, $params, $query, $req) + ), + )* + (m, p) => { + return Err(Error::bad_request(format!( + "Unknown API endpoint: {} {}", + m, p + ))) + } + } + } + }}; + (@@gen_parse_request $api:ident, (), $query: expr, $req:expr) => {{ + paste!( + [< $api Request >] + ) + }}; + (@@gen_parse_request $api:ident, (body), $query: expr, $req:expr) => {{ + paste!({ + parse_json_body::< [<$api Request>], _, Error>($req).await? + }) + }}; + (@@gen_parse_request $api:ident, (body_field, $($conv:ident $(($conv_arg:expr))? :: $param:ident),*), $query: expr, $req:expr) + => + {{ + paste!({ + let body = parse_json_body::< [<$api RequestBody>], _, Error>($req).await?; + [< $api Request >] { + body, + $( + $param: router_match!(@@parse_param $query, $conv $(($conv_arg))?, $param), + )+ + } + }) + }}; + (@@gen_parse_request $api:ident, ($($conv:ident $(($conv_arg:expr))? :: $param:ident),*), $query: expr, $req:expr) + => + {{ + paste!({ + [< $api Request >] { + $( + $param: router_match!(@@parse_param $query, $conv $(($conv_arg))?, $param), + )+ + } + }) + }}; (@gen_parser ($keyword:expr, $key:ident, $query:expr, $header:expr), key: [$($kw_k:ident $(if $required_k:ident)? $(header $header_k:expr)? => $api_k:ident $(($($conv_k:ident :: $param_k:ident),*))?,)*], no_key: [$($kw_nk:ident $(if $required_nk:ident)? $(if_header $header_nk:expr)? => $api_nk:ident $(($($conv_nk:ident :: $param_nk:ident),*))?,)*]) => {{ @@ -102,6 +164,15 @@ macro_rules! router_match { .parse() .map_err(|_| Error::bad_request("Failed to parse query parameter"))? }}; + (@@parse_param $query:expr, parse_default($default:expr), $param:ident) => {{ + // extract and parse mandatory query parameter + // both missing and un-parseable parameters are reported as errors + $query.$param.take().map(|x| x + .parse() + .map_err(|_| Error::bad_request("Failed to parse query parameter"))) + .transpose()? + .unwrap_or($default) + }}; (@func $(#[$doc:meta])* pub enum Endpoint { diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index f9dafa10..3820ad8f 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::sync::Arc; use async_trait::async_trait; @@ -356,8 +357,8 @@ impl ApiHandler for S3ApiServer { } impl ApiEndpoint for S3ApiEndpoint { - fn name(&self) -> &'static str { - self.endpoint.name() + fn name(&self) -> Cow<'_, str> { + Cow::borrowed(self.endpoint.name()) } fn add_span_attributes(&self, span: SpanRef<'_>) { From af1a53083452e7953736261db57aea4a68aa4278 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 15:44:14 +0100 Subject: [PATCH 015/192] admin api: refactor using macro --- src/api/admin/api.rs | 174 ++++++++---------------------------- src/api/admin/api_server.rs | 18 ++-- src/api/admin/macros.rs | 58 ++++++++++++ src/api/admin/mod.rs | 1 + src/api/admin/router_v2.rs | 2 +- src/api/generic_server.rs | 2 +- src/api/k2v/api_server.rs | 4 +- src/api/s3/api_server.rs | 4 +- 8 files changed, 113 insertions(+), 150 deletions(-) create mode 100644 src/api/admin/macros.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index b0ab058a..c8fad95b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -2,161 +2,63 @@ use std::net::SocketAddr; use std::sync::Arc; use async_trait::async_trait; +use paste::paste; use serde::{Deserialize, Serialize}; use garage_model::garage::Garage; use crate::admin::error::Error; +use crate::admin::macros::*; use crate::admin::EndpointHandler; use crate::helpers::is_default; -pub enum AdminApiRequest { +// This generates the following: +// - An enum AdminApiRequest that contains a variant for all endpoints +// - An enum AdminApiResponse that contains a variant for all non-special endpoints +// - AdminApiRequest::name() that returns the name of the endpoint +// - impl EndpointHandler for AdminApiHandler, that uses the impl EndpointHandler +// of each request type below for non-special endpoints +admin_endpoints![ // Special endpoints of the Admin API - Options(OptionsRequest), - CheckDomain(CheckDomainRequest), - Health(HealthRequest), - Metrics(MetricsRequest), + @special Options, + @special CheckDomain, + @special Health, + @special Metrics, // Cluster operations - GetClusterStatus(GetClusterStatusRequest), - GetClusterHealth(GetClusterHealthRequest), - ConnectClusterNodes(ConnectClusterNodesRequest), - GetClusterLayout(GetClusterLayoutRequest), - UpdateClusterLayout(UpdateClusterLayoutRequest), - ApplyClusterLayout(ApplyClusterLayoutRequest), - RevertClusterLayout(RevertClusterLayoutRequest), + GetClusterStatus, + GetClusterHealth, + ConnectClusterNodes, + GetClusterLayout, + UpdateClusterLayout, + ApplyClusterLayout, + RevertClusterLayout, // Access key operations - ListKeys(ListKeysRequest), - GetKeyInfo(GetKeyInfoRequest), - CreateKey(CreateKeyRequest), - ImportKey(ImportKeyRequest), - UpdateKey(UpdateKeyRequest), - DeleteKey(DeleteKeyRequest), + ListKeys, + GetKeyInfo, + CreateKey, + ImportKey, + UpdateKey, + DeleteKey, // Bucket operations - ListBuckets(ListBucketsRequest), - GetBucketInfo(GetBucketInfoRequest), - CreateBucket(CreateBucketRequest), - UpdateBucket(UpdateBucketRequest), - DeleteBucket(DeleteBucketRequest), + ListBuckets, + GetBucketInfo, + CreateBucket, + UpdateBucket, + DeleteBucket, // Operations on permissions for keys on buckets - BucketAllowKey(BucketAllowKeyRequest), - BucketDenyKey(BucketDenyKeyRequest), + BucketAllowKey, + BucketDenyKey, // Operations on bucket aliases - GlobalAliasBucket(GlobalAliasBucketRequest), - GlobalUnaliasBucket(GlobalUnaliasBucketRequest), - LocalAliasBucket(LocalAliasBucketRequest), - LocalUnaliasBucket(LocalUnaliasBucketRequest), -} - -#[derive(Serialize)] -#[serde(untagged)] -pub enum AdminApiResponse { - // Cluster operations - GetClusterStatus(GetClusterStatusResponse), - GetClusterHealth(GetClusterHealthResponse), - ConnectClusterNodes(ConnectClusterNodesResponse), - GetClusterLayout(GetClusterLayoutResponse), - UpdateClusterLayout(UpdateClusterLayoutResponse), - ApplyClusterLayout(ApplyClusterLayoutResponse), - RevertClusterLayout(RevertClusterLayoutResponse), - - // Access key operations - ListKeys(ListKeysResponse), - GetKeyInfo(GetKeyInfoResponse), - CreateKey(CreateKeyResponse), - ImportKey(ImportKeyResponse), - UpdateKey(UpdateKeyResponse), - DeleteKey(DeleteKeyResponse), - - // Bucket operations - ListBuckets(ListBucketsResponse), - GetBucketInfo(GetBucketInfoResponse), - CreateBucket(CreateBucketResponse), - UpdateBucket(UpdateBucketResponse), - DeleteBucket(DeleteBucketResponse), - - // Operations on permissions for keys on buckets - BucketAllowKey(BucketAllowKeyResponse), - BucketDenyKey(BucketDenyKeyResponse), - - // Operations on bucket aliases - GlobalAliasBucket(GlobalAliasBucketResponse), - GlobalUnaliasBucket(GlobalUnaliasBucketResponse), - LocalAliasBucket(LocalAliasBucketResponse), - LocalUnaliasBucket(LocalUnaliasBucketResponse), -} - -#[async_trait] -impl EndpointHandler for AdminApiRequest { - type Response = AdminApiResponse; - - async fn handle(self, garage: &Arc) -> Result { - Ok(match self { - Self::Options | Self::CheckDomain | Self::Health | Self::Metrics => unreachable!(), - // Cluster operations - Self::GetClusterStatus(req) => { - AdminApiResponse::GetClusterStatus(req.handle(garage).await?) - } - Self::GetClusterHealth(req) => { - AdminApiResponse::GetClusterHealth(req.handle(garage).await?) - } - Self::ConnectClusterNodes(req) => { - AdminApiResponse::ConnectClusterNodes(req.handle(garage).await?) - } - Self::GetClusterLayout(req) => { - AdminApiResponse::GetClusterLayout(req.handle(garage).await?) - } - Self::UpdateClusterLayout(req) => { - AdminApiResponse::UpdateClusterLayout(req.handle(garage).await?) - } - Self::ApplyClusterLayout(req) => { - AdminApiResponse::ApplyClusterLayout(req.handle(garage).await?) - } - Self::RevertClusterLayout(req) => { - AdminApiResponse::RevertClusterLayout(req.handle(garage).await?) - } - - // Access key operations - Self::ListKeys(req) => AdminApiResponse::ListKeys(req.handle(garage).await?), - Self::GetKeyInfo(req) => AdminApiResponse::GetKeyInfo(req.handle(garage).await?), - Self::CreateKey(req) => AdminApiResponse::CreateKey(req.handle(garage).await?), - Self::ImportKey(req) => AdminApiResponse::ImportKey(req.handle(garage).await?), - Self::UpdateKey(req) => AdminApiResponse::UpdateKey(req.handle(garage).await?), - Self::DeleteKey(req) => AdminApiResponse::DeleteKey(req.handle(garage).await?), - - // Bucket operations - Self::ListBuckets(req) => AdminApiResponse::ListBuckets(req.handle(garage).await?), - Self::GetBucketInfo(req) => AdminApiResponse::GetBucketInfo(req.handle(garage).await?), - Self::CreateBucket(req) => AdminApiResponse::CreateBucket(req.handle(garage).await?), - Self::UpdateBucket(req) => AdminApiResponse::UpdateBucket(req.handle(garage).await?), - Self::DeleteBucket(req) => AdminApiResponse::DeleteBucket(req.handle(garage).await?), - - // Operations on permissions for keys on buckets - Self::BucketAllowKey(req) => { - AdminApiResponse::BucketAllowKey(req.handle(garage).await?) - } - Self::BucketDenyKey(req) => AdminApiResponse::BucketDenyKey(req.handle(garage).await?), - - // Operations on bucket aliases - Self::GlobalAliasBucket(req) => { - AdminApiResponse::GlobalAliasBucket(req.handle(garage).await?) - } - Self::GlobalUnaliasBucket(req) => { - AdminApiResponse::GlobalUnaliasBucket(req.handle(garage).await?) - } - Self::LocalAliasBucket(req) => { - AdminApiResponse::LocalAliasBucket(req.handle(garage).await?) - } - Self::LocalUnaliasBucket(req) => { - AdminApiResponse::LocalUnaliasBucket(req.handle(garage).await?) - } - }) - } -} + GlobalAliasBucket, + GlobalUnaliasBucket, + LocalAliasBucket, + LocalUnaliasBucket, +]; // ********************************************** // Special endpoints diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index b235dafc..e00f17c4 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -1,10 +1,10 @@ use std::borrow::Cow; -use std::collections::HashMap; use std::sync::Arc; use argon2::password_hash::PasswordHash; use async_trait::async_trait; +use http::header::AUTHORIZATION; use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; use tokio::sync::watch; @@ -16,7 +16,6 @@ use opentelemetry_prometheus::PrometheusExporter; use prometheus::{Encoder, TextEncoder}; use garage_model::garage::Garage; -use garage_rpc::system::ClusterHealthStatus; use garage_util::error::Error as GarageError; use garage_util::socket_address::UnixOrTCPSocketAddress; @@ -26,6 +25,7 @@ use crate::admin::api::*; use crate::admin::error::*; use crate::admin::router_v0; use crate::admin::router_v1; +use crate::admin::Authorization; use crate::admin::EndpointHandler; use crate::helpers::*; @@ -40,7 +40,7 @@ pub struct AdminApiServer { } enum Endpoint { - Old(endpoint_v1::Endpoint), + Old(router_v1::Endpoint), New(String), } @@ -112,7 +112,7 @@ impl ApiHandler for AdminApiServer { fn parse_endpoint(&self, req: &Request) -> Result { if req.uri().path().starts_with("/v0/") { let endpoint_v0 = router_v0::Endpoint::from_request(req)?; - let endpoint_v1 = router_v1::Endpoint::from_v0(endpoint_v0); + let endpoint_v1 = router_v1::Endpoint::from_v0(endpoint_v0)?; Ok(Endpoint::Old(endpoint_v1)) } else if req.uri().path().starts_with("/v1/") { let endpoint_v1 = router_v1::Endpoint::from_request(req)?; @@ -127,6 +127,8 @@ impl ApiHandler for AdminApiServer { req: Request, endpoint: Endpoint, ) -> Result, Error> { + let auth_header = req.headers().get(AUTHORIZATION).clone(); + let request = match endpoint { Endpoint::Old(endpoint_v1) => { todo!() // TODO: convert from old semantics, if possible @@ -147,7 +149,7 @@ impl ApiHandler for AdminApiServer { }; if let Some(password_hash) = required_auth_hash { - match req.headers().get("Authorization") { + match auth_header { None => return Err(Error::forbidden("Authorization token must be provided")), Some(authorization) => { verify_bearer_token(&authorization, password_hash)?; @@ -169,10 +171,10 @@ impl ApiHandler for AdminApiServer { } impl ApiEndpoint for Endpoint { - fn name(&self) -> Cow<'_, str> { + fn name(&self) -> Cow<'static, str> { match self { - Self::Old(endpoint_v1) => Cow::owned(format!("v1:{}", endpoint_v1.name)), - Self::New(path) => Cow::borrowed(&path), + Self::Old(endpoint_v1) => Cow::Owned(format!("v1:{}", endpoint_v1.name())), + Self::New(path) => Cow::Owned(path.clone()), } } diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs new file mode 100644 index 00000000..a12dc40b --- /dev/null +++ b/src/api/admin/macros.rs @@ -0,0 +1,58 @@ +macro_rules! admin_endpoints { + [ + $(@special $special_endpoint:ident,)* + $($endpoint:ident,)* + ] => { + paste! { + pub enum AdminApiRequest { + $( + $special_endpoint( [<$special_endpoint Request>] ), + )* + $( + $endpoint( [<$endpoint Request>] ), + )* + } + + #[derive(Serialize)] + #[serde(untagged)] + pub enum AdminApiResponse { + $( + $endpoint( [<$endpoint Response>] ), + )* + } + + impl AdminApiRequest { + fn name(&self) -> &'static str { + match self { + $( + Self::$special_endpoint(_) => stringify!($special_endpoint), + )* + $( + Self::$endpoint(_) => stringify!($endpoint), + )* + } + } + } + + #[async_trait] + impl EndpointHandler for AdminApiRequest { + type Response = AdminApiResponse; + + async fn handle(self, garage: &Arc) -> Result { + Ok(match self { + $( + AdminApiRequest::$special_endpoint(_) => panic!( + concat!(stringify!($special_endpoint), " needs to go through a special handler") + ), + )* + $( + AdminApiRequest::$endpoint(req) => AdminApiResponse::$endpoint(req.handle(garage).await?), + )* + }) + } + } + } + }; +} + +pub(crate) use admin_endpoints; diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index f4c37298..86f5bcac 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -1,5 +1,6 @@ pub mod api_server; mod error; +mod macros; pub mod api; mod router_v0; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 9d203500..f9a976c4 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -15,7 +15,7 @@ impl AdminApiRequest { /// Determine which S3 endpoint a request is for using the request, and a bucket which was /// possibly extracted from the Host header. /// Returns Self plus bucket name, if endpoint is not Endpoint::ListBuckets - pub async fn from_request(req: Request) -> Result { + pub async fn from_request(req: Request) -> Result { let uri = req.uri().clone(); let path = uri.path(); let query = uri.query(); diff --git a/src/api/generic_server.rs b/src/api/generic_server.rs index ce2ff7b7..5a9b29eb 100644 --- a/src/api/generic_server.rs +++ b/src/api/generic_server.rs @@ -38,7 +38,7 @@ use garage_util::socket_address::UnixOrTCPSocketAddress; use crate::helpers::{BoxBody, ErrorBody}; pub(crate) trait ApiEndpoint: Send + Sync + 'static { - fn name(&self) -> Cow<'_, str>; + fn name(&self) -> Cow<'static, str>; fn add_span_attributes(&self, span: SpanRef<'_>); } diff --git a/src/api/k2v/api_server.rs b/src/api/k2v/api_server.rs index 35931914..863452e6 100644 --- a/src/api/k2v/api_server.rs +++ b/src/api/k2v/api_server.rs @@ -182,8 +182,8 @@ impl ApiHandler for K2VApiServer { } impl ApiEndpoint for K2VApiEndpoint { - fn name(&self) -> Cow<'_, str> { - Cow::borrowed(self.endpoint.name()) + fn name(&self) -> Cow<'static, str> { + Cow::Borrowed(self.endpoint.name()) } fn add_span_attributes(&self, span: SpanRef<'_>) { diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index 3820ad8f..2b638b15 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -357,8 +357,8 @@ impl ApiHandler for S3ApiServer { } impl ApiEndpoint for S3ApiEndpoint { - fn name(&self) -> Cow<'_, str> { - Cow::borrowed(self.endpoint.name()) + fn name(&self) -> Cow<'static, str> { + Cow::Borrowed(self.endpoint.name()) } fn add_span_attributes(&self, span: SpanRef<'_>) { From 5037b97dd41cb668289708384c13006f5db2afd7 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 15:59:32 +0100 Subject: [PATCH 016/192] admin api: add compatibility from v1/ to v2/ --- src/api/admin/api_server.rs | 6 +- src/api/admin/router_v1.rs | 10 --- src/api/admin/router_v2.rs | 169 ++++++++++++++++++++++++------------ src/api/router_macros.rs | 4 +- 4 files changed, 118 insertions(+), 71 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index e00f17c4..2f2e3284 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -127,12 +127,10 @@ impl ApiHandler for AdminApiServer { req: Request, endpoint: Endpoint, ) -> Result, Error> { - let auth_header = req.headers().get(AUTHORIZATION).clone(); + let auth_header = req.headers().get(AUTHORIZATION).cloned(); let request = match endpoint { - Endpoint::Old(endpoint_v1) => { - todo!() // TODO: convert from old semantics, if possible - } + Endpoint::Old(endpoint_v1) => AdminApiRequest::from_v1(endpoint_v1, req).await?, Endpoint::New(_) => AdminApiRequest::from_request(req).await?, }; diff --git a/src/api/admin/router_v1.rs b/src/api/admin/router_v1.rs index d69675cc..7e738145 100644 --- a/src/api/admin/router_v1.rs +++ b/src/api/admin/router_v1.rs @@ -4,7 +4,6 @@ use hyper::{Method, Request}; use crate::admin::error::*; use crate::admin::router_v0; -use crate::admin::Authorization; use crate::router_macros::*; router_match! {@func @@ -205,15 +204,6 @@ impl Endpoint { ))), } } - /// Get the kind of authorization which is required to perform the operation. - pub fn authorization_type(&self) -> Authorization { - match self { - Self::Health => Authorization::None, - Self::CheckDomain => Authorization::None, - Self::Metrics => Authorization::MetricsToken, - _ => Authorization::AdminToken, - } - } } generateQueryParameters! { diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index f9a976c4..e0c54f0e 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -6,7 +6,7 @@ use paste::paste; use crate::admin::api::*; use crate::admin::error::*; -//use crate::admin::router_v1; +use crate::admin::router_v1; use crate::admin::Authorization; use crate::helpers::*; use crate::router_macros::*; @@ -67,82 +67,141 @@ impl AdminApiRequest { Ok(res) } - /* - /// Some endpoints work exactly the same in their v1/ version as they did in their v0/ version. - /// For these endpoints, we can convert a v0/ call to its equivalent as if it was made using - /// its v1/ URL. - pub fn from_v0(v0_endpoint: router_v0::Endpoint) -> Result { - match v0_endpoint { - // Cluster endpoints - router_v0::Endpoint::ConnectClusterNodes => Ok(Self::ConnectClusterNodes), - // - GetClusterStatus: response format changed - // - GetClusterHealth: response format changed - // Layout endpoints - router_v0::Endpoint::RevertClusterLayout => Ok(Self::RevertClusterLayout), - // - GetClusterLayout: response format changed - // - UpdateClusterLayout: query format changed - // - ApplyCusterLayout: response format changed + /// Some endpoints work exactly the same in their v2/ version as they did in their v1/ version. + /// For these endpoints, we can convert a v1/ call to its equivalent as if it was made using + /// its v2/ URL. + pub async fn from_v1( + v1_endpoint: router_v1::Endpoint, + req: Request, + ) -> Result { + use router_v1::Endpoint; - // Key endpoints - router_v0::Endpoint::ListKeys => Ok(Self::ListKeys), - router_v0::Endpoint::CreateKey => Ok(Self::CreateKey), - router_v0::Endpoint::GetKeyInfo { id, search } => Ok(Self::GetKeyInfo { + match v1_endpoint { + Endpoint::GetClusterStatus => { + Ok(AdminApiRequest::GetClusterStatus(GetClusterStatusRequest)) + } + Endpoint::GetClusterHealth => { + Ok(AdminApiRequest::GetClusterHealth(GetClusterHealthRequest)) + } + Endpoint::ConnectClusterNodes => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::ConnectClusterNodes(req)) + } + + // Layout + Endpoint::GetClusterLayout => { + Ok(AdminApiRequest::GetClusterLayout(GetClusterLayoutRequest)) + } + Endpoint::UpdateClusterLayout => { + let updates = parse_json_body::(req).await?; + Ok(AdminApiRequest::UpdateClusterLayout(updates)) + } + Endpoint::ApplyClusterLayout => { + let param = parse_json_body::(req).await?; + Ok(AdminApiRequest::ApplyClusterLayout(param)) + } + Endpoint::RevertClusterLayout => Ok(AdminApiRequest::RevertClusterLayout( + RevertClusterLayoutRequest, + )), + + // Keys + Endpoint::ListKeys => Ok(AdminApiRequest::ListKeys(ListKeysRequest)), + Endpoint::GetKeyInfo { id, search, - show_secret_key: Some("true".into()), - }), - router_v0::Endpoint::DeleteKey { id } => Ok(Self::DeleteKey { id }), - // - UpdateKey: response format changed (secret key no longer returned) - - // Bucket endpoints - router_v0::Endpoint::GetBucketInfo { id, global_alias } => { - Ok(Self::GetBucketInfo { id, global_alias }) + show_secret_key, + } => { + let show_secret_key = show_secret_key.map(|x| x == "true").unwrap_or(false); + Ok(AdminApiRequest::GetKeyInfo(GetKeyInfoRequest { + id, + search, + show_secret_key, + })) + } + Endpoint::CreateKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::CreateKey(req)) + } + Endpoint::ImportKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::ImportKey(req)) + } + Endpoint::UpdateKey { id } => { + let body = parse_json_body::(req).await?; + Ok(AdminApiRequest::UpdateKey(UpdateKeyRequest { id, body })) + } + Endpoint::DeleteKey { id } => Ok(AdminApiRequest::DeleteKey(DeleteKeyRequest { id })), + + // Buckets + Endpoint::ListBuckets => Ok(AdminApiRequest::ListBuckets(ListBucketsRequest)), + Endpoint::GetBucketInfo { id, global_alias } => { + Ok(AdminApiRequest::GetBucketInfo(GetBucketInfoRequest { + id, + global_alias, + })) + } + Endpoint::CreateBucket => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::CreateBucket(req)) + } + Endpoint::DeleteBucket { id } => { + Ok(AdminApiRequest::DeleteBucket(DeleteBucketRequest { id })) + } + Endpoint::UpdateBucket { id } => { + let body = parse_json_body::(req).await?; + Ok(AdminApiRequest::UpdateBucket(UpdateBucketRequest { + id, + body, + })) } - router_v0::Endpoint::ListBuckets => Ok(Self::ListBuckets), - router_v0::Endpoint::CreateBucket => Ok(Self::CreateBucket), - router_v0::Endpoint::DeleteBucket { id } => Ok(Self::DeleteBucket { id }), - router_v0::Endpoint::UpdateBucket { id } => Ok(Self::UpdateBucket { id }), // Bucket-key permissions - router_v0::Endpoint::BucketAllowKey => Ok(Self::BucketAllowKey), - router_v0::Endpoint::BucketDenyKey => Ok(Self::BucketDenyKey), - - // Bucket alias endpoints - router_v0::Endpoint::GlobalAliasBucket { id, alias } => { - Ok(Self::GlobalAliasBucket { id, alias }) + Endpoint::BucketAllowKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::BucketAllowKey(BucketAllowKeyRequest(req))) } - router_v0::Endpoint::GlobalUnaliasBucket { id, alias } => { - Ok(Self::GlobalUnaliasBucket { id, alias }) + Endpoint::BucketDenyKey => { + let req = parse_json_body::(req).await?; + Ok(AdminApiRequest::BucketDenyKey(BucketDenyKeyRequest(req))) } - router_v0::Endpoint::LocalAliasBucket { + // Bucket aliasing + Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::GlobalAliasBucket( + GlobalAliasBucketRequest { id, alias }, + )), + Endpoint::GlobalUnaliasBucket { id, alias } => Ok( + AdminApiRequest::GlobalUnaliasBucket(GlobalUnaliasBucketRequest { id, alias }), + ), + Endpoint::LocalAliasBucket { id, access_key_id, alias, - } => Ok(Self::LocalAliasBucket { + } => Ok(AdminApiRequest::LocalAliasBucket(LocalAliasBucketRequest { + access_key_id, + id, + alias, + })), + Endpoint::LocalUnaliasBucket { id, access_key_id, alias, - }), - router_v0::Endpoint::LocalUnaliasBucket { - id, - access_key_id, - alias, - } => Ok(Self::LocalUnaliasBucket { - id, - access_key_id, - alias, - }), + } => Ok(AdminApiRequest::LocalUnaliasBucket( + LocalUnaliasBucketRequest { + access_key_id, + id, + alias, + }, + )), // For endpoints that have different body content syntax, issue // deprecation warning _ => Err(Error::bad_request(format!( - "v0/ endpoint is no longer supported: {}", - v0_endpoint.name() + "v1/ endpoint is no longer supported: {}", + v1_endpoint.name() ))), } } - */ + /// Get the kind of authorization which is required to perform the operation. pub fn authorization_type(&self) -> Authorization { match self { diff --git a/src/api/router_macros.rs b/src/api/router_macros.rs index acbe097c..e8c99909 100644 --- a/src/api/router_macros.rs +++ b/src/api/router_macros.rs @@ -165,8 +165,8 @@ macro_rules! router_match { .map_err(|_| Error::bad_request("Failed to parse query parameter"))? }}; (@@parse_param $query:expr, parse_default($default:expr), $param:ident) => {{ - // extract and parse mandatory query parameter - // both missing and un-parseable parameters are reported as errors + // extract and parse optional query parameter + // using provided value as default if paramter is missing $query.$param.take().map(|x| x .parse() .map_err(|_| Error::bad_request("Failed to parse query parameter"))) From ed58f8b0fe3c44eac7416b3aaa444d1b568f8918 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 16:18:48 +0100 Subject: [PATCH 017/192] admin api: update semantics of some endpoints, and update doc --- doc/drafts/admin-api.md | 110 +++++++++++++++++++++++++----------- src/api/admin/api.rs | 12 ++-- src/api/admin/api_server.rs | 4 +- src/api/admin/bucket.rs | 8 +-- src/api/admin/macros.rs | 2 +- src/api/admin/router_v2.rs | 44 ++++++++++----- 6 files changed, 122 insertions(+), 58 deletions(-) diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index a614af58..92b6a6db 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -13,8 +13,9 @@ We will bump the version numbers prefixed to each API endpoint each time the syn or semantics change, meaning that code that relies on these endpoints will break when changes are introduced. -The Garage administration API was introduced in version 0.7.2, this document -does not apply to older versions of Garage. +The Garage administration API was introduced in version 0.7.2, and was +changed several times. +This document applies only to the Garage v2 API (starting with Garage v2.0.0). ## Access control @@ -52,11 +53,18 @@ Returns an HTTP status 200 if the node is ready to answer user's requests, and an HTTP status 503 (Service Unavailable) if there are some partitions for which a quorum of nodes is not available. A simple textual message is also returned in a body with content-type `text/plain`. -See `/v1/health` for an API that also returns JSON output. +See `/v2/health` for an API that also returns JSON output. + +### Other special endpoints + +#### CheckDomain `GET /check?domain=` + +Checks whether this Garage cluster serves a website for domain ``. +Returns HTTP 200 Ok if yes, or HTTP 4xx if no website is available for this domain. ### Cluster operations -#### GetClusterStatus `GET /v1/status` +#### GetClusterStatus `GET /v2/GetClusterStatus` Returns the cluster's current status in JSON, including: @@ -70,7 +78,7 @@ Example response body: ```json { "node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df", - "garageVersion": "v1.0.1", + "garageVersion": "v2.0.0", "garageFeatures": [ "k2v", "lmdb", @@ -169,7 +177,7 @@ Example response body: } ``` -#### GetClusterHealth `GET /v1/health` +#### GetClusterHealth `GET /v2/GetClusterHealth` Returns the cluster's current health in JSON format, with the following variables: @@ -202,7 +210,7 @@ Example response body: } ``` -#### ConnectClusterNodes `POST /v1/connect` +#### ConnectClusterNodes `POST /v2/ConnectClusterNodes` Instructs this Garage node to connect to other Garage nodes at specified addresses. @@ -232,7 +240,7 @@ Example response: ] ``` -#### GetClusterLayout `GET /v1/layout` +#### GetClusterLayout `GET /v2/GetClusterLayout` Returns the cluster's current layout in JSON, including: @@ -293,7 +301,7 @@ Example response body: } ``` -#### UpdateClusterLayout `POST /v1/layout` +#### UpdateClusterLayout `POST /v2/UpdateClusterLayout` Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls @@ -330,7 +338,7 @@ This returns the new cluster layout with the proposed staged changes, as returned by GetClusterLayout. -#### ApplyClusterLayout `POST /v1/layout/apply` +#### ApplyClusterLayout `POST /v2/ApplyClusterLayout` Applies to the cluster the layout changes currently registered as staged layout changes. @@ -350,7 +358,7 @@ existing layout in the cluster. This returns the message describing all the calculations done to compute the new layout, as well as the description of the layout as returned by GetClusterLayout. -#### RevertClusterLayout `POST /v1/layout/revert` +#### RevertClusterLayout `POST /v2/RevertClusterLayout` Clears all of the staged layout changes. @@ -374,7 +382,7 @@ as returned by GetClusterLayout. ### Access key operations -#### ListKeys `GET /v1/key` +#### ListKeys `GET /v2/ListKeys` Returns all API access keys in the cluster. @@ -393,8 +401,8 @@ Example response: ] ``` -#### GetKeyInfo `GET /v1/key?id=` -#### GetKeyInfo `GET /v1/key?search=` +#### GetKeyInfo `GET /v2/GetKeyInfo?id=` +#### GetKeyInfo `GET /v2/GetKeyInfo?search=` Returns information about the requested API access key. @@ -468,7 +476,7 @@ Example response: } ``` -#### CreateKey `POST /v1/key` +#### CreateKey `POST /v2/CreateKey` Creates a new API access key. @@ -483,7 +491,7 @@ Request body format: This returns the key info, including the created secret key, in the same format as the result of GetKeyInfo. -#### ImportKey `POST /v1/key/import` +#### ImportKey `POST /v2/ImportKey` Imports an existing API key. This will check that the imported key is in the valid format, i.e. @@ -501,7 +509,7 @@ Request body format: This returns the key info in the same format as the result of GetKeyInfo. -#### UpdateKey `POST /v1/key?id=` +#### UpdateKey `POST /v2/UpdateKey?id=` Updates information about the specified API access key. @@ -523,14 +531,14 @@ The possible flags in `allow` and `deny` are: `createBucket`. This returns the key info in the same format as the result of GetKeyInfo. -#### DeleteKey `DELETE /v1/key?id=` +#### DeleteKey `POST /v2/DeleteKey?id=` Deletes an API access key. ### Bucket operations -#### ListBuckets `GET /v1/bucket` +#### ListBuckets `GET /v2/ListBuckets` Returns all storage buckets in the cluster. @@ -572,8 +580,8 @@ Example response: ] ``` -#### GetBucketInfo `GET /v1/bucket?id=` -#### GetBucketInfo `GET /v1/bucket?globalAlias=` +#### GetBucketInfo `GET /v2/GetBucketInfo?id=` +#### GetBucketInfo `GET /v2/GetBucketInfo?globalAlias=` Returns information about the requested storage bucket. @@ -616,7 +624,7 @@ Example response: } ``` -#### CreateBucket `POST /v1/bucket` +#### CreateBucket `POST /v2/CreateBucket` Creates a new storage bucket. @@ -656,7 +664,7 @@ or no alias at all. Technically, you can also specify both `globalAlias` and `localAlias` and that would create two aliases, but I don't see why you would want to do that. -#### UpdateBucket `PUT /v1/bucket?id=` +#### UpdateBucket `POST /v2/UpdateBucket?id=` Updates configuration of the given bucket. @@ -688,7 +696,7 @@ In `quotas`: new values of `maxSize` and `maxObjects` must both be specified, or to remove the quotas. An absent value will be considered the same as a `null`. It is not possible to change only one of the two quotas. -#### DeleteBucket `DELETE /v1/bucket?id=` +#### DeleteBucket `POST /v2/DeleteBucket?id=` Deletes a storage bucket. A bucket cannot be deleted if it is not empty. @@ -697,7 +705,7 @@ Warning: this will delete all aliases associated with the bucket! ### Operations on permissions for keys on buckets -#### BucketAllowKey `POST /v1/bucket/allow` +#### BucketAllowKey `POST /v2/BucketAllowKey` Allows a key to do read/write/owner operations on a bucket. @@ -718,7 +726,7 @@ Request body format: Flags in `permissions` which have the value `true` will be activated. Other flags will remain unchanged. -#### BucketDenyKey `POST /v1/bucket/deny` +#### BucketDenyKey `POST /v2/BucketDenyKey` Denies a key from doing read/write/owner operations on a bucket. @@ -742,19 +750,57 @@ Other flags will remain unchanged. ### Operations on bucket aliases -#### GlobalAliasBucket `PUT /v1/bucket/alias/global?id=&alias=` +#### GlobalAliasBucket `POST /v2/GlobalAliasBucket` -Empty body. Creates a global alias for a bucket. +Creates a global alias for a bucket. -#### GlobalUnaliasBucket `DELETE /v1/bucket/alias/global?id=&alias=` +Request body format: + +```json +{ + "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", + "alias": "the-bucket" +} +``` + +#### GlobalUnaliasBucket `POST /v2/GlobalUnaliasBucket` Removes a global alias for a bucket. -#### LocalAliasBucket `PUT /v1/bucket/alias/local?id=&accessKeyId=&alias=` +Request body format: -Empty body. Creates a local alias for a bucket in the namespace of a specific access key. +```json +{ + "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", + "alias": "the-bucket" +} +``` -#### LocalUnaliasBucket `DELETE /v1/bucket/alias/local?id=&accessKeyId&alias=` +#### LocalAliasBucket `POST /v2/LocalAliasBucket` + +Creates a local alias for a bucket in the namespace of a specific access key. + +Request body format: + +```json +{ + "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", + "accessKeyId": "GK31c2f218a2e44f485b94239e", + "alias": "my-bucket" +} +``` + +#### LocalUnaliasBucket `POST /v2/LocalUnaliasBucket` Removes a local alias for a bucket in the namespace of a specific access key. +Request body format: + +```json +{ + "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", + "accessKeyId": "GK31c2f218a2e44f485b94239e", + "alias": "my-bucket" +} +``` + diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index c8fad95b..457863e0 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -500,8 +500,9 @@ pub struct BucketDenyKeyResponse(pub GetBucketInfoResponse); // ---- GlobalAliasBucket ---- +#[derive(Deserialize)] pub struct GlobalAliasBucketRequest { - pub id: String, + pub bucket_id: String, pub alias: String, } @@ -510,8 +511,9 @@ pub struct GlobalAliasBucketResponse(pub GetBucketInfoResponse); // ---- GlobalUnaliasBucket ---- +#[derive(Deserialize)] pub struct GlobalUnaliasBucketRequest { - pub id: String, + pub bucket_id: String, pub alias: String, } @@ -520,8 +522,9 @@ pub struct GlobalUnaliasBucketResponse(pub GetBucketInfoResponse); // ---- LocalAliasBucket ---- +#[derive(Deserialize)] pub struct LocalAliasBucketRequest { - pub id: String, + pub bucket_id: String, pub access_key_id: String, pub alias: String, } @@ -531,8 +534,9 @@ pub struct LocalAliasBucketResponse(pub GetBucketInfoResponse); // ---- LocalUnaliasBucket ---- +#[derive(Deserialize)] pub struct LocalUnaliasBucketRequest { - pub id: String, + pub bucket_id: String, pub access_key_id: String, pub alias: String, } diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 2f2e3284..82337b7e 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -39,7 +39,7 @@ pub struct AdminApiServer { admin_token: Option, } -enum Endpoint { +pub enum Endpoint { Old(router_v1::Endpoint), New(String), } @@ -159,7 +159,7 @@ impl ApiHandler for AdminApiServer { AdminApiRequest::Options(req) => req.handle(&self.garage).await, AdminApiRequest::CheckDomain(req) => req.handle(&self.garage).await, AdminApiRequest::Health(req) => req.handle(&self.garage).await, - AdminApiRequest::Metrics(req) => self.handle_metrics(), + AdminApiRequest::Metrics(_req) => self.handle_metrics(), req => { let res = req.handle(&self.garage).await?; json_ok_response(&res) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index f9accba5..8e19b93e 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -457,7 +457,7 @@ impl EndpointHandler for GlobalAliasBucketRequest { type Response = GlobalAliasBucketResponse; async fn handle(self, garage: &Arc) -> Result { - let bucket_id = parse_bucket_id(&self.id)?; + let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -476,7 +476,7 @@ impl EndpointHandler for GlobalUnaliasBucketRequest { type Response = GlobalUnaliasBucketResponse; async fn handle(self, garage: &Arc) -> Result { - let bucket_id = parse_bucket_id(&self.id)?; + let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -495,7 +495,7 @@ impl EndpointHandler for LocalAliasBucketRequest { type Response = LocalAliasBucketResponse; async fn handle(self, garage: &Arc) -> Result { - let bucket_id = parse_bucket_id(&self.id)?; + let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -514,7 +514,7 @@ impl EndpointHandler for LocalUnaliasBucketRequest { type Response = LocalUnaliasBucketResponse; async fn handle(self, garage: &Arc) -> Result { - let bucket_id = parse_bucket_id(&self.id)?; + let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index a12dc40b..d8c8f6dc 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -22,7 +22,7 @@ macro_rules! admin_endpoints { } impl AdminApiRequest { - fn name(&self) -> &'static str { + pub fn name(&self) -> &'static str { match self { $( Self::$special_endpoint(_) => stringify!($special_endpoint), diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index e0c54f0e..dacf6793 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -43,22 +43,22 @@ impl AdminApiRequest { POST UpdateKey (body_field, query::id), POST CreateKey (body), POST ImportKey (body), - DELETE DeleteKey (query::id), + POST DeleteKey (query::id), GET ListKeys (), // Bucket endpoints GET GetBucketInfo (query_opt::id, query_opt::global_alias), GET ListBuckets (), POST CreateBucket (body), - DELETE DeleteBucket (query::id), - PUT UpdateBucket (body_field, query::id), + POST DeleteBucket (query::id), + POST UpdateBucket (body_field, query::id), // Bucket-key permissions POST BucketAllowKey (body), POST BucketDenyKey (body), // Bucket aliases - PUT GlobalAliasBucket (query::id, query::alias), - DELETE GlobalUnaliasBucket (query::id, query::alias), - PUT LocalAliasBucket (query::id, query::access_key_id, query::alias), - DELETE LocalUnaliasBucket (query::id, query::access_key_id, query::alias), + POST GlobalAliasBucket (body), + POST GlobalUnaliasBucket (body), + POST LocalAliasBucket (body), + POST LocalUnaliasBucket (body), ]); if let Some(message) = query.nonempty_message() { @@ -131,7 +131,11 @@ impl AdminApiRequest { let body = parse_json_body::(req).await?; Ok(AdminApiRequest::UpdateKey(UpdateKeyRequest { id, body })) } - Endpoint::DeleteKey { id } => Ok(AdminApiRequest::DeleteKey(DeleteKeyRequest { id })), + + // DeleteKey semantics changed: + // - in v1/ : HTTP DELETE => HTTP 204 No Content + // - in v2/ : HTTP POST => HTTP 200 Ok + // Endpoint::DeleteKey { id } => Ok(AdminApiRequest::DeleteKey(DeleteKeyRequest { id })), // Buckets Endpoint::ListBuckets => Ok(AdminApiRequest::ListBuckets(ListBucketsRequest)), @@ -145,9 +149,13 @@ impl AdminApiRequest { let req = parse_json_body::(req).await?; Ok(AdminApiRequest::CreateBucket(req)) } - Endpoint::DeleteBucket { id } => { - Ok(AdminApiRequest::DeleteBucket(DeleteBucketRequest { id })) - } + + // DeleteBucket semantics changed:: + // - in v1/ : HTTP DELETE => HTTP 204 No Content + // - in v2/ : HTTP POST => HTTP 200 Ok + // Endpoint::DeleteBucket { id } => { + // Ok(AdminApiRequest::DeleteBucket(DeleteBucketRequest { id })) + // } Endpoint::UpdateBucket { id } => { let body = parse_json_body::(req).await?; Ok(AdminApiRequest::UpdateBucket(UpdateBucketRequest { @@ -167,10 +175,16 @@ impl AdminApiRequest { } // Bucket aliasing Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::GlobalAliasBucket( - GlobalAliasBucketRequest { id, alias }, + GlobalAliasBucketRequest { + bucket_id: id, + alias, + }, )), Endpoint::GlobalUnaliasBucket { id, alias } => Ok( - AdminApiRequest::GlobalUnaliasBucket(GlobalUnaliasBucketRequest { id, alias }), + AdminApiRequest::GlobalUnaliasBucket(GlobalUnaliasBucketRequest { + bucket_id: id, + alias, + }), ), Endpoint::LocalAliasBucket { id, @@ -178,7 +192,7 @@ impl AdminApiRequest { alias, } => Ok(AdminApiRequest::LocalAliasBucket(LocalAliasBucketRequest { access_key_id, - id, + bucket_id: id, alias, })), Endpoint::LocalUnaliasBucket { @@ -188,7 +202,7 @@ impl AdminApiRequest { } => Ok(AdminApiRequest::LocalUnaliasBucket( LocalUnaliasBucketRequest { access_key_id, - id, + bucket_id: id, alias, }, )), From f538dc34d3ad6f6c0d01d40f8f1f6b81458534db Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 17:07:34 +0100 Subject: [PATCH 018/192] admin api: make all requests and responses (de)serializable --- src/api/admin/api.rs | 126 ++++++++++++++++++++++----------------- src/api/admin/cluster.rs | 10 ++-- src/api/admin/macros.rs | 3 +- 3 files changed, 79 insertions(+), 60 deletions(-) diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 457863e0..01b4f928 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -64,14 +64,18 @@ admin_endpoints![ // Special endpoints // ********************************************** +#[derive(Serialize, Deserialize)] pub struct OptionsRequest; +#[derive(Serialize, Deserialize)] pub struct CheckDomainRequest { pub domain: String, } +#[derive(Serialize, Deserialize)] pub struct HealthRequest; +#[derive(Serialize, Deserialize)] pub struct MetricsRequest; // ********************************************** @@ -80,21 +84,22 @@ pub struct MetricsRequest; // ---- GetClusterStatus ---- +#[derive(Serialize, Deserialize)] pub struct GetClusterStatusRequest; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetClusterStatusResponse { pub node: String, - pub garage_version: &'static str, - pub garage_features: Option<&'static [&'static str]>, - pub rust_version: &'static str, + pub garage_version: String, + pub garage_features: Option>, + pub rust_version: String, pub db_engine: String, pub layout_version: u64, pub nodes: Vec, } -#[derive(Serialize, Default)] +#[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct NodeResp { pub id: String, @@ -110,7 +115,7 @@ pub struct NodeResp { pub metadata_partition: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeRoleResp { pub id: String, @@ -119,7 +124,7 @@ pub struct NodeRoleResp { pub tags: Vec, } -#[derive(Serialize, Default)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreeSpaceResp { pub available: u64, @@ -128,12 +133,13 @@ pub struct FreeSpaceResp { // ---- GetClusterHealth ---- +#[derive(Serialize, Deserialize)] pub struct GetClusterHealthRequest; -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetClusterHealthResponse { - pub status: &'static str, + pub status: String, pub known_nodes: usize, pub connected_nodes: usize, pub storage_nodes: usize, @@ -145,13 +151,13 @@ pub struct GetClusterHealthResponse { // ---- ConnectClusterNodes ---- -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectClusterNodesRequest(pub Vec); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ConnectClusterNodesResponse(pub Vec); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectClusterNodeResponse { pub success: bool, @@ -160,9 +166,10 @@ pub struct ConnectClusterNodeResponse { // ---- GetClusterLayout ---- +#[derive(Serialize, Deserialize)] pub struct GetClusterLayoutRequest; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetClusterLayoutResponse { pub version: u64, @@ -193,21 +200,21 @@ pub enum NodeRoleChangeEnum { // ---- UpdateClusterLayout ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct UpdateClusterLayoutRequest(pub Vec); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); // ---- ApplyClusterLayout ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutRequest { pub version: u64, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutResponse { pub message: Vec, @@ -216,9 +223,10 @@ pub struct ApplyClusterLayoutResponse { // ---- RevertClusterLayout ---- +#[derive(Serialize, Deserialize)] pub struct RevertClusterLayoutRequest; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); // ********************************************** @@ -227,12 +235,13 @@ pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); // ---- ListKeys ---- +#[derive(Serialize, Deserialize)] pub struct ListKeysRequest; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ListKeysResponse(pub Vec); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListKeysResponseItem { pub id: String, @@ -241,13 +250,14 @@ pub struct ListKeysResponseItem { // ---- GetKeyInfo ---- +#[derive(Serialize, Deserialize)] pub struct GetKeyInfoRequest { pub id: Option, pub search: Option, pub show_secret_key: bool, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetKeyInfoResponse { pub name: String, @@ -265,7 +275,7 @@ pub struct KeyPerm { pub create_bucket: bool, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeyInfoBucketResponse { pub id: String, @@ -287,18 +297,18 @@ pub struct ApiBucketKeyPerm { // ---- CreateKey ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateKeyRequest { pub name: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct CreateKeyResponse(pub GetKeyInfoResponse); // ---- ImportKey ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ImportKeyRequest { pub access_key_id: String, @@ -306,20 +316,21 @@ pub struct ImportKeyRequest { pub name: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ImportKeyResponse(pub GetKeyInfoResponse); // ---- UpdateKey ---- +#[derive(Serialize, Deserialize)] pub struct UpdateKeyRequest { pub id: String, pub body: UpdateKeyRequestBody, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct UpdateKeyResponse(pub GetKeyInfoResponse); -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateKeyRequestBody { // TODO: id (get parameter) goes here @@ -330,11 +341,12 @@ pub struct UpdateKeyRequestBody { // ---- DeleteKey ---- +#[derive(Serialize, Deserialize)] pub struct DeleteKeyRequest { pub id: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct DeleteKeyResponse; // ********************************************** @@ -343,12 +355,13 @@ pub struct DeleteKeyResponse; // ---- ListBuckets ---- +#[derive(Serialize, Deserialize)] pub struct ListBucketsRequest; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ListBucketsResponse(pub Vec); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListBucketsResponseItem { pub id: String, @@ -356,7 +369,7 @@ pub struct ListBucketsResponseItem { pub local_aliases: Vec, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BucketLocalAlias { pub access_key_id: String, @@ -365,12 +378,13 @@ pub struct BucketLocalAlias { // ---- GetBucketInfo ---- +#[derive(Serialize, Deserialize)] pub struct GetBucketInfoRequest { pub id: Option, pub global_alias: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoResponse { pub id: String, @@ -388,14 +402,14 @@ pub struct GetBucketInfoResponse { pub quotas: ApiBucketQuotas, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoWebsiteResponse { pub index_document: String, pub error_document: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoKey { pub access_key_id: String, @@ -413,17 +427,17 @@ pub struct ApiBucketQuotas { // ---- CreateBucket ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateBucketRequest { pub global_alias: Option, pub local_alias: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct CreateBucketResponse(pub GetBucketInfoResponse); -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateBucketLocalAlias { pub access_key_id: String, @@ -434,22 +448,23 @@ pub struct CreateBucketLocalAlias { // ---- UpdateBucket ---- +#[derive(Serialize, Deserialize)] pub struct UpdateBucketRequest { pub id: String, pub body: UpdateBucketRequestBody, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct UpdateBucketResponse(pub GetBucketInfoResponse); -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateBucketRequestBody { pub website_access: Option, pub quotas: Option, } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateBucketWebsiteAccess { pub enabled: bool, @@ -459,11 +474,12 @@ pub struct UpdateBucketWebsiteAccess { // ---- DeleteBucket ---- +#[derive(Serialize, Deserialize)] pub struct DeleteBucketRequest { pub id: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct DeleteBucketResponse; // ********************************************** @@ -472,13 +488,13 @@ pub struct DeleteBucketResponse; // ---- BucketAllowKey ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct BucketAllowKeyRequest(pub BucketKeyPermChangeRequest); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct BucketAllowKeyResponse(pub GetBucketInfoResponse); -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BucketKeyPermChangeRequest { pub bucket_id: String, @@ -488,10 +504,10 @@ pub struct BucketKeyPermChangeRequest { // ---- BucketDenyKey ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct BucketDenyKeyRequest(pub BucketKeyPermChangeRequest); -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct BucketDenyKeyResponse(pub GetBucketInfoResponse); // ********************************************** @@ -500,46 +516,46 @@ pub struct BucketDenyKeyResponse(pub GetBucketInfoResponse); // ---- GlobalAliasBucket ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct GlobalAliasBucketRequest { pub bucket_id: String, pub alias: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct GlobalAliasBucketResponse(pub GetBucketInfoResponse); // ---- GlobalUnaliasBucket ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct GlobalUnaliasBucketRequest { pub bucket_id: String, pub alias: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct GlobalUnaliasBucketResponse(pub GetBucketInfoResponse); // ---- LocalAliasBucket ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct LocalAliasBucketRequest { pub bucket_id: String, pub access_key_id: String, pub alias: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct LocalAliasBucketResponse(pub GetBucketInfoResponse); // ---- LocalUnaliasBucket ---- -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct LocalUnaliasBucketRequest { pub bucket_id: String, pub access_key_id: String, pub alias: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct LocalUnaliasBucketResponse(pub GetBucketInfoResponse); diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index c7eb7e7d..3327cb4c 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -112,9 +112,10 @@ impl EndpointHandler for GetClusterStatusRequest { Ok(GetClusterStatusResponse { node: hex::encode(garage.system.id), - garage_version: garage_util::version::garage_version(), - garage_features: garage_util::version::garage_features(), - rust_version: garage_util::version::rust_version(), + garage_version: garage_util::version::garage_version().to_string(), + garage_features: garage_util::version::garage_features() + .map(|features| features.iter().map(ToString::to_string).collect()), + rust_version: garage_util::version::rust_version().to_string(), db_engine: garage.db.engine(), layout_version: layout.current().version, nodes, @@ -134,7 +135,8 @@ impl EndpointHandler for GetClusterHealthRequest { ClusterHealthStatus::Healthy => "healthy", ClusterHealthStatus::Degraded => "degraded", ClusterHealthStatus::Unavailable => "unavailable", - }, + } + .to_string(), known_nodes: health.known_nodes, connected_nodes: health.connected_nodes, storage_nodes: health.storage_nodes, diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index d8c8f6dc..d68ba37f 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -4,6 +4,7 @@ macro_rules! admin_endpoints { $($endpoint:ident,)* ] => { paste! { + #[derive(Serialize, Deserialize)] pub enum AdminApiRequest { $( $special_endpoint( [<$special_endpoint Request>] ), @@ -13,7 +14,7 @@ macro_rules! admin_endpoints { )* } - #[derive(Serialize)] + #[derive(Serialize, Deserialize)] #[serde(untagged)] pub enum AdminApiResponse { $( From a99925e0ed6981eafd25b9b3031f4e28c3d92f86 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 17:39:22 +0100 Subject: [PATCH 019/192] admin api: initialize v2 openapi spec from v1 --- doc/api/garage-admin-v2.yml | 1362 +++++++++++++++++++++++++++++++++++ 1 file changed, 1362 insertions(+) create mode 100644 doc/api/garage-admin-v2.yml diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml new file mode 100644 index 00000000..1ea77b2e --- /dev/null +++ b/doc/api/garage-admin-v2.yml @@ -0,0 +1,1362 @@ +openapi: 3.0.0 +info: + version: v0.9.0 + title: Garage Administration API v0+garage-v0.9.0 + description: | + Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks. + + *Disclaimer: The API is not stable yet, hence its v0 tag. The API can change at any time, and changes can include breaking backward compatibility. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!* +paths: + /health: + get: + tags: + - Nodes + operationId: "GetHealth" + summary: "Cluster health report" + description: | + Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total). + responses: + '500': + description: | + The server can not answer your request because it is in a bad state + '200': + description: | + Information about the queried node, its environment and the current layout + content: + application/json: + schema: + type: object + required: [ status, knownNodes, connectedNodes, storageNodes, storageNodesOk, partitions, partitionsQuorum, partitionsAllOk ] + properties: + status: + type: string + example: "healthy" + knownNodes: + type: integer + format: int64 + example: 4 + connectedNodes: + type: integer + format: int64 + example: 4 + storageNodes: + type: integer + format: int64 + example: 3 + storageNodesOk: + type: integer + format: int64 + example: 3 + partitions: + type: integer + format: int64 + example: 256 + partitionsQuorum: + type: integer + format: int64 + example: 256 + partitionsAllOk: + type: integer + format: int64 + example: 256 + /status: + get: + tags: + - Nodes + operationId: "GetNodes" + summary: "Describe cluster" + description: | + Returns the cluster's current status, including: + - ID of the node being queried and its version of the Garage daemon + - Live nodes + - Currently configured cluster layout + - Staged changes to the cluster layout + + *Capacity is given in bytes* + responses: + '500': + description: | + The server can not answer your request because it is in a bad state + '200': + description: | + Information about the queried node, its environment and the current layout + content: + application/json: + schema: + type: object + required: [ node, garageVersion, garageFeatures, rustVersion, dbEngine, knownNodes, layout ] + properties: + node: + type: string + example: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" + garageVersion: + type: string + example: "v0.9.0" + garageFeatures: + type: array + items: + type: string + example: + - "k2v" + - "lmdb" + - "sqlite" + - "consul-discovery" + - "kubernetes-discovery" + - "metrics" + - "telemetry-otlp" + - "bundled-libs" + rustVersion: + type: string + example: "1.68.0" + dbEngine: + type: string + example: "LMDB (using Heed crate)" + knownNodes: + type: array + example: + - id: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" + addr: "10.0.0.11:3901" + isUp: true + lastSeenSecsAgo: 9 + hostname: orion + - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" + addr: "10.0.0.12:3901" + isUp: true + lastSeenSecsAgo: 13 + hostname: pegasus + - id: "e2ee7984ee65b260682086ec70026165903c86e601a4a5a501c1900afe28d84b" + addr: "10.0.0.13:3901" + isUp: true + lastSeenSecsAgo: 2 + hostname: neptune + items: + $ref: '#/components/schemas/NodeNetworkInfo' + layout: + $ref: '#/components/schemas/ClusterLayout' + + /connect: + post: + tags: + - Nodes + operationId: "AddNode" + summary: "Connect a new node" + description: | + Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start. + requestBody: + required: true + content: + application/json: + schema: + type: array + example: + - "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f@10.0.0.11:3901" + - "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff@10.0.0.12:3901" + items: + type: string + + responses: + '500': + description: | + The server can not answer your request because it is in a bad state + '400': + description: | + Your request is malformed, check your JSON + '200': + description: | + The request has been handled correctly but it does not mean that all connection requests succeeded; some might have fail, you need to check the body! + content: + application/json: + schema: + type: array + example: + - success: true + error: + - success: false + error: "Handshake error" + items: + type: object + properties: + success: + type: boolean + example: true + error: + type: string + nullable: true + example: null + + /layout: + get: + tags: + - Layout + operationId: "GetLayout" + summary: "Details on the current and staged layout" + description: | + Returns the cluster's current layout, including: + - Currently configured cluster layout + - Staged changes to the cluster layout + + *Capacity is given in bytes* + *The info returned by this endpoint is a subset of the info returned by `GET /status`.* + responses: + '500': + description: | + The server can not answer your request because it is in a bad state + '200': + description: | + Returns the cluster's current cluster layout: + - Currently configured cluster layout + - Staged changes to the cluster layout + content: + application/json: + schema: + $ref: '#/components/schemas/ClusterLayout' + + post: + tags: + - Layout + operationId: "AddLayout" + summary: "Send modifications to the cluster layout" + description: | + Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /layout`. Once the set of staged changes is satisfactory, the user may call `POST /layout/apply` to apply the changed changes, or `POST /layout/revert` to clear all of the staged changes in the layout. + + Setting the capacity to `null` will configure the node as a gateway. + Otherwise, capacity must be now set in bytes (before Garage 0.9 it was arbitrary weights). + For example to declare 100GB, you must set `capacity: 100000000000`. + + Garage uses internally the International System of Units (SI), it assumes that 1kB = 1000 bytes, and displays storage as kB, MB, GB (and not KiB, MiB, GiB that assume 1KiB = 1024 bytes). + requestBody: + description: | + To add a new node to the layout or to change the configuration of an existing node, simply set the values you want (`zone`, `capacity`, and `tags`). + To remove a node, simply pass the `remove: true` field. + This logic is represented in OpenAPI with a "One Of" object. + + Contrary to the CLI that may update only a subset of the fields capacity, zone and tags, when calling this API all of these values must be specified. + required: true + content: + application/json: + schema: + type: array + example: + - id: "e2ee7984ee65b260682086ec70026165903c86e601a4a5a501c1900afe28d84b" + zone: "geneva" + capacity: 100000000000 + tags: + - gateway + - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" + remove: true + items: + $ref: '#/components/schemas/NodeRoleChange' + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Invalid syntax or requested change" + '200': + description: "The layout modification has been correctly staged" + content: + application/json: + schema: + $ref: '#/components/schemas/ClusterLayout' + + /layout/apply: + post: + tags: + - Layout + operationId: "ApplyLayout" + summary: "Apply staged layout" + description: | + Applies to the cluster the layout changes currently registered as staged layout changes. + + *Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.* + requestBody: + description: | + Similarly to the CLI, the body must include the version of the new layout that will be created, which MUST be 1 + the value of the currently existing layout in the cluster. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LayoutVersion' + + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Invalid syntax or requested change" + '200': + description: "The staged layout has been applied as the new layout of the cluster, a rebalance has been triggered." + content: + application/json: + schema: + type: object + required: [ message, layout ] + properties: + message: + type: array + items: + type: string + example: + - "==== COMPUTATION OF A NEW PARTITION ASSIGNATION ====" + - "" + - "Partitions are replicated 1 times on at least 1 distinct zones." + - "" + - "Optimal partition size: 419.4 MB (3 B in previous layout)" + - "Usable capacity / total cluster capacity: 107.4 GB / 107.4 GB (100.0 %)" + - "Effective capacity (replication factor 1): 107.4 GB" + - "" + - "A total of 0 new copies of partitions need to be transferred." + - "" + - "dc1 Tags Partitions Capacity Usable capacity\n 6a8e08af2aab1083 a,v 256 (0 new) 107.4 GB 107.4 GB (100.0%)\n TOTAL 256 (256 unique) 107.4 GB 107.4 GB (100.0%)\n\n" + layout: + $ref: '#/components/schemas/ClusterLayout' + + + /layout/revert: + post: + tags: + - Layout + operationId: "RevertLayout" + summary: "Clear staged layout" + description: | + Clears all of the staged layout changes. + requestBody: + description: | + Reverting the staged changes is done by incrementing the version number and clearing the contents of the staged change list. Similarly to the CLI, the body must include the incremented version number, which MUST be 1 + the value of the currently existing layout in the cluster. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LayoutVersion' + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Invalid syntax or requested change" + '200': + description: "The staged layout has been cleared, you can start again sending modification from a fresh copy with `POST /layout`." + + "/key?list": + get: + tags: + - Key + operationId: "ListKeys" + summary: "List all keys" + description: | + Returns all API access keys in the cluster. + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '200': + description: | + Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string) + content: + application/json: + schema: + type: array + example: + - id: "GK31c2f218a2e44f485b94239e" + name: "test-key" + - id: "GKe10061ac9c2921f09e4c5540" + name: "" + items: + type: object + required: [ id ] + properties: + id: + type: string + name: + type: string + post: + tags: + - Key + operationId: "AddKey" + summary: "Create a new API key" + description: | + Creates a new API access key. + requestBody: + description: | + You can set a friendly name for this key. + If you don't want to, you can set the name to `null`. + + *Note: the secret key is returned in the response.* + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + nullable: true + example: "test-key" + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Invalid syntax or requested change" + '200': + description: "The key has been added" + content: + application/json: + schema: + $ref: '#/components/schemas/KeyInfo' + + "/key": + get: + tags: + - Key + operationId: "GetKey" + summary: "Get key information" + description: | + Return information about a specific key like its identifiers, its permissions and buckets on which it has permissions. + You can search by specifying the exact key identifier (`id`) or by specifying a pattern (`search`). + + For confidentiality reasons, the secret key is not returned by default: you must pass the `showSecretKey` query parameter to get it. + parameters: + - name: id + in: query + description: | + The exact API access key generated by Garage. + + Incompatible with `search`. + example: "GK31c2f218a2e44f485b94239e" + schema: + type: string + - name: search + in: query + description: | + A pattern (beginning or full string) corresponding to a key identifier or friendly name. + + Incompatible with `id`. + example: "test-k" + schema: + type: string + - name: showSecretKey + in: query + schema: + type: string + default: "false" + enum: + - "true" + - "false" + example: "true" + required: false + description: "Wether or not the secret key should be returned in the response" + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '200': + description: | + Returns information about the key + content: + application/json: + schema: + $ref: '#/components/schemas/KeyInfo' + + delete: + tags: + - Key + operationId: "DeleteKey" + summary: "Delete a key" + description: | + Delete a key from the cluster. Its access will be removed from all the buckets. Buckets are not automatically deleted and can be dangling. You should manually delete them before. + parameters: + - name: id + in: query + required: true + description: "The exact API access key generated by Garage" + example: "GK31c2f218a2e44f485b94239e" + schema: + type: string + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '200': + description: "The key has been deleted" + + + post: + tags: + - Key + operationId: "UpdateKey" + summary: "Update a key" + description: | + Updates information about the specified API access key. + + *Note: the secret key is not returned in the response, `null` is sent instead.* + parameters: + - name: id + in: query + required: true + description: "The exact API access key generated by Garage" + example: "GK31c2f218a2e44f485b94239e" + schema: + type: string + requestBody: + description: | + For a given key, provide a first set with the permissions to grant, and a second set with the permissions to remove + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: "test-key" + allow: + type: object + example: + properties: + createBucket: + type: boolean + example: true + deny: + type: object + properties: + createBucket: + type: boolean + example: true + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Invalid syntax or requested change" + '200': + description: | + Returns information about the key + content: + application/json: + schema: + $ref: '#/components/schemas/KeyInfo' + + + /key/import: + post: + tags: + - Key + operationId: "ImportKey" + summary: "Import an existing key" + description: | + Imports an existing API key. This feature must only be used for migrations and backup restore. + + **Do not use it to generate custom key identifiers or you will break your Garage cluster.** + requestBody: + description: | + Information on the key to import + required: true + content: + application/json: + schema: + type: object + required: [ name, accessKeyId, secretAccessKey ] + properties: + name: + type: string + example: "test-key" + nullable: true + accessKeyId: + type: string + example: "GK31c2f218a2e44f485b94239e" + secretAccessKey: + type: string + example: "b892c0665f0ada8a4755dae98baa3b133590e11dae3bcc1f9d769d67f16c3835" + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Invalid syntax or requested change" + '200': + description: "The key has been imported into the system" + content: + application/json: + schema: + $ref: '#/components/schemas/KeyInfo' + + "/bucket?list": + get: + tags: + - Bucket + operationId: "ListBuckets" + summary: "List all buckets" + description: | + List all the buckets on the cluster with their UUID and their global and local aliases. + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '200': + description: | + Returns the UUID of the bucket and all its aliases + content: + application/json: + schema: + type: array + example: + - id: "70dc3bed7fe83a75e46b66e7ddef7d56e65f3c02f9f80b6749fb97eccb5e1033" + globalAliases: + - "container_registry" + - id: "96470e0df00ec28807138daf01915cfda2bee8eccc91dea9558c0b4855b5bf95" + localAliases: + - alias: "my_documents" + accessKeyid: "GK31c2f218a2e44f485b94239e" + - id: "d7452a935e663fc1914f3a5515163a6d3724010ce8dfd9e4743ca8be5974f995" + globalAliases: + - "example.com" + - "www.example.com" + localAliases: + - alias: "corp_website" + accessKeyId: "GKe10061ac9c2921f09e4c5540" + - alias: "web" + accessKeyid: "GK31c2f218a2e44f485b94239e" + - id: "" + items: + type: object + required: [ id ] + properties: + id: + type: string + globalAliases: + type: array + items: + type: string + localAliases: + type: array + items: + type: object + required: [ alias, accessKeyId ] + properties: + alias: + type: string + accessKeyId: + type: string + + /bucket: + post: + tags: + - Bucket + operationId: "CreateBucket" + summary: "Create a bucket" + description: | + Creates a new bucket, either with a global alias, a local one, or no alias at all. + Technically, you can also specify both `globalAlias` and `localAlias` and that would create two aliases. + requestBody: + description: | + Aliases to put on the new bucket + required: true + content: + application/json: + schema: + type: object + required: [ ] + properties: + globalAlias: + type: string + example: "my_documents" + localAlias: + type: object + properties: + accessKeyId: + type: string + alias: + type: string + allow: + type: object + properties: + read: + type: boolean + example: true + write: + type: boolean + example: true + owner: + type: boolean + example: true + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "The payload is not formatted correctly" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + get: + tags: + - Bucket + operationId: "GetBucketInfo" + summary: "Get a bucket" + description: | + Given a bucket identifier (`id`) or a global alias (`alias`), get its information. + It includes its aliases, its web configuration, keys that have some permissions + on it, some statistics (number of objects, size), number of dangling multipart uploads, + and its quotas (if any). + parameters: + - name: id + in: query + description: | + The exact bucket identifier, a 32 bytes hexadecimal string. + + Incompatible with `alias`. + example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87" + schema: + type: string + - name: alias + in: query + description: | + The exact global alias of one of the existing buckets. + + Incompatible with `id`. + example: "my_documents" + schema: + type: string + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + + delete: + tags: + - Bucket + operationId: "DeleteBucket" + summary: "Delete a bucket" + description: | + Delete a bucket.Deletes a storage bucket. A bucket cannot be deleted if it is not empty. + + **Warning:** this will delete all aliases associated with the bucket! + parameters: + - name: id + in: query + required: true + description: "The exact bucket identifier, a 32 bytes hexadecimal string" + example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87" + schema: + type: string + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bucket is not empty" + '404': + description: "Bucket not found" + '204': + description: Bucket has been deleted + + + + put: + tags: + - Bucket + operationId: "UpdateBucket" + summary: "Update a bucket" + description: | + All fields (`websiteAccess` and `quotas`) are optional. + If they are present, the corresponding modifications are applied to the bucket, otherwise nothing is changed. + + In `websiteAccess`: if `enabled` is `true`, `indexDocument` must be specified. + The field `errorDocument` is optional, if no error document is set a generic + error message is displayed when errors happen. Conversely, if `enabled` is + `false`, neither `indexDocument` nor `errorDocument` must be specified. + + In `quotas`: new values of `maxSize` and `maxObjects` must both be specified, or set to `null` + to remove the quotas. An absent value will be considered the same as a `null`. It is not possible + to change only one of the two quotas. + parameters: + - name: id + in: query + required: true + description: "The exact bucket identifier, a 32 bytes hexadecimal string" + example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87" + schema: + type: string + requestBody: + description: | + Requested changes on the bucket. Both root fields are optionals. + required: true + content: + application/json: + schema: + type: object + required: [ ] + properties: + websiteAccess: + type: object + properties: + enabled: + type: boolean + example: true + indexDocument: + type: string + example: "index.html" + errorDocument: + type: string + example: "error/400.html" + quotas: + type: object + properties: + maxSize: + type: integer + format: int64 + nullable: true + example: 19029801 + maxObjects: + type: integer + format: int64 + nullable: true + example: null + + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your body." + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + /bucket/allow: + post: + tags: + - Bucket + operationId: "AllowBucketKey" + summary: "Allow key" + description: | + ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. + + Allows a key to do read/write/owner operations on a bucket. + + Flags in permissions which have the value true will be activated. Other flags will remain unchanged (ie. they will keep their internal value). + + For example, if you set read to true, the key will be allowed to read the bucket. + If you set it to false, the key will keeps its previous read permission. + If you want to disallow read for the key, check the DenyBucketKey operation. + + requestBody: + description: | + Aliases to put on the new bucket + required: true + content: + application/json: + schema: + type: object + required: [ bucketId, accessKeyId, permissions ] + properties: + bucketId: + type: string + example: "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b" + accessKeyId: + type: string + example: "GK31c2f218a2e44f485b94239e" + permissions: + type: object + required: [ read, write, owner ] + properties: + read: + type: boolean + example: true + write: + type: boolean + example: true + owner: + type: boolean + example: true + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your request body" + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + /bucket/deny: + post: + tags: + - Bucket + operationId: "DenyBucketKey" + summary: "Deny key" + description: | + ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. + + Denies a key from doing read/write/owner operations on a bucket. + + Flags in permissions which have the value true will be deactivated. Other flags will remain unchanged. + + For example, if you set read to true, the key will be denied from reading. + If you set read to false, the key will keep its previous permissions. + If you want the key to have the reading permission, check the AllowBucketKey operation. + + requestBody: + description: | + Aliases to put on the new bucket + required: true + content: + application/json: + schema: + type: object + required: [ bucketId, accessKeyId, permissions ] + properties: + bucketId: + type: string + example: "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b" + accessKeyId: + type: string + example: "GK31c2f218a2e44f485b94239e" + permissions: + type: object + required: [ read, write, owner ] + properties: + read: + type: boolean + example: true + write: + type: boolean + example: true + owner: + type: boolean + example: true + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your request body" + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + /bucket/alias/global: + put: + tags: + - Bucket + operationId: "PutBucketGlobalAlias" + summary: "Add a global alias" + description: | + Add a global alias to the target bucket + parameters: + - name: id + in: query + required: true + schema: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + - name: alias + in: query + required: true + example: my_documents + schema: + type: string + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your request body" + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + delete: + tags: + - Bucket + operationId: "DeleteBucketGlobalAlias" + summary: "Delete a global alias" + description: | + Delete a global alias from the target bucket + parameters: + - name: id + in: query + required: true + schema: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + - name: alias + in: query + required: true + schema: + type: string + example: my_documents + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your request body" + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + /bucket/alias/local: + put: + tags: + - Bucket + operationId: "PutBucketLocalAlias" + summary: "Add a local alias" + description: | + Add a local alias, bound to specified account, to the target bucket + parameters: + - name: id + in: query + required: true + schema: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + - name: accessKeyId + in: query + required: true + schema: + type: string + example: GK31c2f218a2e44f485b94239e + - name: alias + in: query + required: true + schema: + type: string + example: my_documents + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your request body" + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + + delete: + tags: + - Bucket + operationId: "DeleteBucketLocalAlias" + summary: "Delete a local alias" + description: | + Delete a local alias, bound to specified account, from the target bucket + parameters: + - name: id + in: query + required: true + schema: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + - name: accessKeyId + in: query + schema: + type: string + required: true + example: GK31c2f218a2e44f485b94239e + - name: alias + in: query + schema: + type: string + required: true + example: my_documents + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "Bad request, check your request body" + '404': + description: "Bucket not found" + '200': + description: Returns exhaustive information about the bucket + content: + application/json: + schema: + $ref: '#/components/schemas/BucketInfo' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + NodeNetworkInfo: + type: object + required: [ addr, isUp, lastSeenSecsAgo, hostname ] + properties: + id: + type: string + example: "6a8e08af2aab1083ebab9b22165ea8b5b9d333b60a39ecd504e85cc1f432c36f" + addr: + type: string + example: "10.0.0.11:3901" + isUp: + type: boolean + example: true + lastSeenSecsAgo: + type: integer + nullable: true + example: 9 + hostname: + type: string + example: "node1" + NodeClusterInfo: + type: object + required: [ id, zone, tags ] + properties: + zone: + type: string + example: dc1 + capacity: + type: integer + format: int64 + nullable: true + example: 4 + tags: + type: array + description: | + User defined tags, put whatever makes sense for you, these tags are not interpreted by Garage + example: + - gateway + - fast + items: + type: string + NodeRoleChange: + oneOf: + - $ref: '#/components/schemas/NodeRoleRemove' + - $ref: '#/components/schemas/NodeRoleUpdate' + NodeRoleRemove: + type: object + required: [ id, remove ] + properties: + id: + type: string + example: "6a8e08af2aab1083ebab9b22165ea8b5b9d333b60a39ecd504e85cc1f432c36f" + remove: + type: boolean + example: true + NodeRoleUpdate: + type: object + required: [ id, zone, capacity, tags ] + properties: + id: + type: string + example: "6a8e08af2aab1083ebab9b22165ea8b5b9d333b60a39ecd504e85cc1f432c36f" + zone: + type: string + example: "dc1" + capacity: + type: integer + format: int64 + nullable: true + example: 150000000000 + tags: + type: array + items: + type: string + example: + - gateway + - fast + + ClusterLayout: + type: object + required: [ version, roles, stagedRoleChanges ] + properties: + version: + type: integer + example: 12 + roles: + type: array + example: + - id: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" + zone: "madrid" + capacity: 300000000000 + tags: + - fast + - amd64 + - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" + zone: "geneva" + capacity: 700000000000 + tags: + - arm64 + items: + $ref: '#/components/schemas/NodeClusterInfo' + stagedRoleChanges: + type: array + example: + - id: "e2ee7984ee65b260682086ec70026165903c86e601a4a5a501c1900afe28d84b" + zone: "geneva" + capacity: 800000000000 + tags: + - gateway + - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" + remove: true + items: + $ref: '#/components/schemas/NodeRoleChange' + LayoutVersion: + type: object + required: [ version ] + properties: + version: + type: integer + #format: int64 + example: 13 + + KeyInfo: + type: object + properties: + name: + type: string + example: "test-key" + accessKeyId: + type: string + example: "GK31c2f218a2e44f485b94239e" + secretAccessKey: + type: string + nullable: true + example: "b892c0665f0ada8a4755dae98baa3b133590e11dae3bcc1f9d769d67f16c3835" + permissions: + type: object + properties: + createBucket: + type: boolean + example: false + buckets: + type: array + items: + type: object + properties: + id: + type: string + example: "70dc3bed7fe83a75e46b66e7ddef7d56e65f3c02f9f80b6749fb97eccb5e1033" + globalAliases: + type: array + items: + type: string + example: "my-bucket" + localAliases: + type: array + items: + type: string + example: "GK31c2f218a2e44f485b94239e:localname" + permissions: + type: object + properties: + read: + type: boolean + example: true + write: + type: boolean + example: true + owner: + type: boolean + example: false + BucketInfo: + type: object + properties: + id: + type: string + example: afa8f0a22b40b1247ccd0affb869b0af5cff980924a20e4b5e0720a44deb8d39 + globalAliases: + type: array + items: + type: string + example: "my_documents" + websiteAccess: + type: boolean + example: true + websiteConfig: + type: object + nullable: true + properties: + indexDocument: + type: string + example: "index.html" + errorDocument: + type: string + example: "error/400.html" + keys: + type: array + items: + $ref: '#/components/schemas/BucketKeyInfo' + objects: + type: integer + format: int64 + example: 14827 + bytes: + type: integer + format: int64 + example: 13189855625 + unfinishedUploads: + type: integer + example: 0 + quotas: + type: object + properties: + maxSize: + nullable: true + type: integer + format: int64 + example: null + maxObjects: + nullable: true + type: integer + format: int64 + example: null + + + BucketKeyInfo: + type: object + properties: + accessKeyId: + type: string + name: + type: string + permissions: + type: object + properties: + read: + type: boolean + example: true + write: + type: boolean + example: true + owner: + type: boolean + example: true + bucketLocalAliases: + type: array + items: + type: string + example: "my_documents" + + +security: + - bearerAuth: [] + +servers: + - description: A local server + url: http://localhost:3903/v1/ From d5ad797ad762dee4fc1244ad15fbee208ae58480 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 17:56:30 +0100 Subject: [PATCH 020/192] admin api: update v2 openapi spec --- doc/api/garage-admin-v2.html | 24 ++++ doc/api/garage-admin-v2.yml | 231 ++++++++++++++++++----------------- 2 files changed, 143 insertions(+), 112 deletions(-) create mode 100644 doc/api/garage-admin-v2.html diff --git a/doc/api/garage-admin-v2.html b/doc/api/garage-admin-v2.html new file mode 100644 index 00000000..d93c2e7d --- /dev/null +++ b/doc/api/garage-admin-v2.html @@ -0,0 +1,24 @@ + + + + Garage Adminstration API v0 + + + + + + + + + + + + + diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 1ea77b2e..e40e0226 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -1,17 +1,17 @@ openapi: 3.0.0 info: - version: v0.9.0 - title: Garage Administration API v0+garage-v0.9.0 + version: v2.0.0 + title: Garage Administration API v0+garage-v2.0.0 description: | Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks. - - *Disclaimer: The API is not stable yet, hence its v0 tag. The API can change at any time, and changes can include breaking backward compatibility. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!* + + *Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!* paths: - /health: + /GetClusterHealth: get: tags: - Nodes - operationId: "GetHealth" + operationId: "GetClusterHealth" summary: "Cluster health report" description: | Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total). @@ -59,11 +59,11 @@ paths: type: integer format: int64 example: 256 - /status: + /GetClusterStatus: get: tags: - Nodes - operationId: "GetNodes" + operationId: "GetClusterStatus" summary: "Describe cluster" description: | Returns the cluster's current status, including: @@ -134,11 +134,11 @@ paths: layout: $ref: '#/components/schemas/ClusterLayout' - /connect: + /ConnectClusterNodes: post: tags: - Nodes - operationId: "AddNode" + operationId: "ConnectClusterNodes" summary: "Connect a new node" description: | Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start. @@ -184,11 +184,11 @@ paths: nullable: true example: null - /layout: + /GetClusterLayout: get: tags: - Layout - operationId: "GetLayout" + operationId: "GetClusterLayout" summary: "Details on the current and staged layout" description: | Returns the cluster's current layout, including: @@ -196,7 +196,7 @@ paths: - Staged changes to the cluster layout *Capacity is given in bytes* - *The info returned by this endpoint is a subset of the info returned by `GET /status`.* + *The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.* responses: '500': description: | @@ -211,13 +211,14 @@ paths: schema: $ref: '#/components/schemas/ClusterLayout' + /UpdateClusterLayout: post: tags: - Layout - operationId: "AddLayout" + operationId: "UpdateClusterLayout" summary: "Send modifications to the cluster layout" description: | - Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /layout`. Once the set of staged changes is satisfactory, the user may call `POST /layout/apply` to apply the changed changes, or `POST /layout/revert` to clear all of the staged changes in the layout. + Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /GetClusterHealth`. Once the set of staged changes is satisfactory, the user may call `POST /ApplyClusterLayout` to apply the changed changes, or `POST /RevertClusterLayout` to clear all of the staged changes in the layout. Setting the capacity to `null` will configure the node as a gateway. Otherwise, capacity must be now set in bytes (before Garage 0.9 it was arbitrary weights). @@ -258,11 +259,11 @@ paths: schema: $ref: '#/components/schemas/ClusterLayout' - /layout/apply: + /ApplyClusterLayout: post: tags: - Layout - operationId: "ApplyLayout" + operationId: "ApplyClusterLayout" summary: "Apply staged layout" description: | Applies to the cluster the layout changes currently registered as staged layout changes. @@ -310,11 +311,11 @@ paths: $ref: '#/components/schemas/ClusterLayout' - /layout/revert: + /RevertClusterLayout: post: tags: - Layout - operationId: "RevertLayout" + operationId: "RevertClusterLayout" summary: "Clear staged layout" description: | Clears all of the staged layout changes. @@ -332,9 +333,9 @@ paths: '400': description: "Invalid syntax or requested change" '200': - description: "The staged layout has been cleared, you can start again sending modification from a fresh copy with `POST /layout`." + description: "The staged layout has been cleared, you can start again sending modification from a fresh copy with `POST /UpdateClusterLayout`." - "/key?list": + /ListKeys: get: tags: - Key @@ -365,10 +366,12 @@ paths: type: string name: type: string + + /CreateKey: post: tags: - Key - operationId: "AddKey" + operationId: "CreateKey" summary: "Create a new API key" description: | Creates a new API access key. @@ -400,11 +403,11 @@ paths: schema: $ref: '#/components/schemas/KeyInfo' - "/key": + /GetKeyInfo: get: tags: - Key - operationId: "GetKey" + operationId: "GetKeyInfo" summary: "Get key information" description: | Return information about a specific key like its identifiers, its permissions and buckets on which it has permissions. @@ -452,7 +455,8 @@ paths: schema: $ref: '#/components/schemas/KeyInfo' - delete: + /DeleteKey: + post: tags: - Key operationId: "DeleteKey" @@ -474,6 +478,7 @@ paths: description: "The key has been deleted" + /UpdateKey: post: tags: - Key @@ -530,7 +535,7 @@ paths: $ref: '#/components/schemas/KeyInfo' - /key/import: + /ImportKey: post: tags: - Key @@ -572,7 +577,7 @@ paths: schema: $ref: '#/components/schemas/KeyInfo' - "/bucket?list": + /ListBuckets: get: tags: - Bucket @@ -629,7 +634,7 @@ paths: accessKeyId: type: string - /bucket: + /CreateBucket: post: tags: - Bucket @@ -646,7 +651,6 @@ paths: application/json: schema: type: object - required: [ ] properties: globalAlias: type: string @@ -681,6 +685,8 @@ paths: application/json: schema: $ref: '#/components/schemas/BucketInfo' + + /GetBucketInfo: get: tags: - Bucket @@ -723,7 +729,8 @@ paths: $ref: '#/components/schemas/BucketInfo' - delete: + /DeleteBucket: + post: tags: - Bucket operationId: "DeleteBucket" @@ -747,12 +754,13 @@ paths: description: "Bucket is not empty" '404': description: "Bucket not found" - '204': + '200': description: Bucket has been deleted - put: + /UpdateBucket: + post: tags: - Bucket operationId: "UpdateBucket" @@ -785,7 +793,6 @@ paths: application/json: schema: type: object - required: [ ] properties: websiteAccess: type: object @@ -827,11 +834,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /bucket/allow: + /BucketAllowKey: post: tags: - Bucket - operationId: "AllowBucketKey" + operationId: "BucketAllowKey" summary: "Allow key" description: | ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. @@ -887,11 +894,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /bucket/deny: + /BucketDenyKey: post: tags: - Bucket - operationId: "DenyBucketKey" + operationId: "BucketDenyKey" summary: "Deny key" description: | ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. @@ -947,27 +954,28 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /bucket/alias/global: - put: + /GlobalAliasBucket: + post: tags: - Bucket - operationId: "PutBucketGlobalAlias" + operationId: "GlobalAliasBucket" summary: "Add a global alias" description: | Add a global alias to the target bucket - parameters: - - name: id - in: query - required: true - schema: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - - name: alias - in: query - required: true - example: my_documents - schema: - type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [bucketId, alias] + properties: + bucketId: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + alias: + type: string + example: my_documents responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." @@ -982,26 +990,28 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - delete: + /GlobalUnaliasBucket: + post: tags: - Bucket - operationId: "DeleteBucketGlobalAlias" + operationId: "GlobalUnaliasBucket" summary: "Delete a global alias" description: | Delete a global alias from the target bucket - parameters: - - name: id - in: query - required: true - schema: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - - name: alias - in: query - required: true - schema: - type: string - example: my_documents + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [bucketId, alias] + properties: + bucketId: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + alias: + type: string + example: my_documents responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." @@ -1016,33 +1026,31 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /bucket/alias/local: - put: + /LocalAliasBucket: + post: tags: - Bucket - operationId: "PutBucketLocalAlias" + operationId: "LocalAliasBucket" summary: "Add a local alias" description: | Add a local alias, bound to specified account, to the target bucket - parameters: - - name: id - in: query - required: true - schema: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - - name: accessKeyId - in: query - required: true - schema: - type: string - example: GK31c2f218a2e44f485b94239e - - name: alias - in: query - required: true - schema: - type: string - example: my_documents + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [bucketId, accessKeyId, alias] + properties: + bucketId: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + accessKeyId: + type: string + example: GK31c2f218a2e44f485b94239e + alias: + type: string + example: my_documents responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." @@ -1057,32 +1065,31 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - delete: + /LocalUnaliasBucket: + post: tags: - Bucket - operationId: "DeleteBucketLocalAlias" + operationId: "LocalUnaliasBucket" summary: "Delete a local alias" description: | Delete a local alias, bound to specified account, from the target bucket - parameters: - - name: id - in: query - required: true - schema: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - - name: accessKeyId - in: query - schema: - type: string - required: true - example: GK31c2f218a2e44f485b94239e - - name: alias - in: query - schema: - type: string - required: true - example: my_documents + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [bucketId, accessKeyId, alias] + properties: + bucketId: + type: string + example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + accessKeyId: + type: string + example: GK31c2f218a2e44f485b94239e + alias: + type: string + example: my_documents responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." @@ -1359,4 +1366,4 @@ security: servers: - description: A local server - url: http://localhost:3903/v1/ + url: http://localhost:3903/v2/ From 4cb45bd398afd7966cec5d4dfa4dd325c114f93c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 18:15:36 +0100 Subject: [PATCH 021/192] admin api: fix CORS to work in browser --- src/api/admin/api_server.rs | 9 +++++++-- src/api/admin/router_v2.rs | 1 + src/api/admin/special.rs | 11 +++++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 82337b7e..92da3245 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use argon2::password_hash::PasswordHash; use async_trait::async_trait; -use http::header::AUTHORIZATION; +use http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION}; use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; use tokio::sync::watch; @@ -134,6 +134,8 @@ impl ApiHandler for AdminApiServer { Endpoint::New(_) => AdminApiRequest::from_request(req).await?, }; + info!("Admin request: {}", request.name()); + let required_auth_hash = match request.authorization_type() { Authorization::None => None, @@ -162,7 +164,10 @@ impl ApiHandler for AdminApiServer { AdminApiRequest::Metrics(_req) => self.handle_metrics(), req => { let res = req.handle(&self.garage).await?; - json_ok_response(&res) + let mut res = json_ok_response(&res)?; + res.headers_mut() + .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); + Ok(res) } } } diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index dacf6793..c7a5e316 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -219,6 +219,7 @@ impl AdminApiRequest { /// Get the kind of authorization which is required to perform the operation. pub fn authorization_type(&self) -> Authorization { match self { + Self::Options(_) => Authorization::None, Self::Health(_) => Authorization::None, Self::CheckDomain(_) => Authorization::None, Self::Metrics(_) => Authorization::MetricsToken, diff --git a/src/api/admin/special.rs b/src/api/admin/special.rs index 0239021a..da3764d9 100644 --- a/src/api/admin/special.rs +++ b/src/api/admin/special.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use async_trait::async_trait; -use http::header::{ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ALLOW}; +use http::header::{ + ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ALLOW, +}; use hyper::{Response, StatusCode}; use garage_model::garage::Garage; @@ -20,9 +22,10 @@ impl EndpointHandler for OptionsRequest { async fn handle(self, _garage: &Arc) -> Result, Error> { Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .header(ALLOW, "OPTIONS, GET, POST") - .header(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS, GET, POST") + .status(StatusCode::OK) + .header(ALLOW, "OPTIONS,GET,POST") + .header(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS,GET,POST") + .header(ACCESS_CONTROL_ALLOW_HEADERS, "authorization,content-type") .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") .body(empty_body())?) } From 2daeb89834cc9f9e38c9625ed9fd84afcd77b3ab Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 18:28:48 +0100 Subject: [PATCH 022/192] admin api: fixes to openapi v2 spec --- doc/api/garage-admin-v2.yml | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index e40e0226..652f8b39 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -319,14 +319,6 @@ paths: summary: "Clear staged layout" description: | Clears all of the staged layout changes. - requestBody: - description: | - Reverting the staged changes is done by incrementing the version number and clearing the contents of the staged change list. Similarly to the CLI, the body must include the incremented version number, which MUST be 1 + the value of the currently existing layout in the cluster. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LayoutVersion' responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." @@ -439,9 +431,9 @@ paths: type: string default: "false" enum: - - "true" - "false" - example: "true" + - "true" + example: "false" required: false description: "Wether or not the secret key should be returned in the response" responses: @@ -837,7 +829,7 @@ paths: /BucketAllowKey: post: tags: - - Bucket + - Permissions operationId: "BucketAllowKey" summary: "Allow key" description: | @@ -897,7 +889,7 @@ paths: /BucketDenyKey: post: tags: - - Bucket + - Permissions operationId: "BucketDenyKey" summary: "Deny key" description: | @@ -957,7 +949,7 @@ paths: /GlobalAliasBucket: post: tags: - - Bucket + - Bucket aliases operationId: "GlobalAliasBucket" summary: "Add a global alias" description: | @@ -993,7 +985,7 @@ paths: /GlobalUnaliasBucket: post: tags: - - Bucket + - Bucket aliases operationId: "GlobalUnaliasBucket" summary: "Delete a global alias" description: | @@ -1029,7 +1021,7 @@ paths: /LocalAliasBucket: post: tags: - - Bucket + - Bucket aliases operationId: "LocalAliasBucket" summary: "Add a local alias" description: | @@ -1068,7 +1060,7 @@ paths: /LocalUnaliasBucket: post: tags: - - Bucket + - Bucket aliases operationId: "LocalUnaliasBucket" summary: "Delete a local alias" description: | From f8ed3fdbc4cd0211f7f7cff2871cfe98e621a9fe Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 18:40:40 +0100 Subject: [PATCH 023/192] fix test_website_check_domain --- src/api/router_macros.rs | 11 +++++++++-- src/garage/tests/s3/website.rs | 9 ++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/api/router_macros.rs b/src/api/router_macros.rs index e8c99909..142cdc11 100644 --- a/src/api/router_macros.rs +++ b/src/api/router_macros.rs @@ -146,7 +146,10 @@ macro_rules! router_match { }}; (@@parse_param $query:expr, query, $param:ident) => {{ // extract mendatory query parameter - $query.$param.take().ok_or_bad_request("Missing argument for endpoint")?.into_owned() + $query.$param.take() + .ok_or_bad_request( + format!("Missing argument `{}` for endpoint", stringify!($param)) + )?.into_owned() }}; (@@parse_param $query:expr, opt_parse, $param:ident) => {{ // extract and parse optional query parameter @@ -160,7 +163,10 @@ macro_rules! router_match { (@@parse_param $query:expr, parse, $param:ident) => {{ // extract and parse mandatory query parameter // both missing and un-parseable parameters are reported as errors - $query.$param.take().ok_or_bad_request("Missing argument for endpoint")? + $query.$param.take() + .ok_or_bad_request( + format!("Missing argument `{}` for endpoint", stringify!($param)) + )? .parse() .map_err(|_| Error::bad_request("Failed to parse query parameter"))? }}; @@ -256,6 +262,7 @@ macro_rules! generateQueryParameters { }, )* $( + // FIXME: remove if !v.is_empty() ? $f_param => if !v.is_empty() { if res.$f_name.replace(v).is_some() { return Err(Error::bad_request(format!( diff --git a/src/garage/tests/s3/website.rs b/src/garage/tests/s3/website.rs index 0cadc388..41d6c879 100644 --- a/src/garage/tests/s3/website.rs +++ b/src/garage/tests/s3/website.rs @@ -427,12 +427,18 @@ async fn test_website_check_domain() { res_body, json!({ "code": "InvalidRequest", - "message": "Bad request: No domain query string found", + "message": "Bad request: Missing argument `domain` for endpoint", "region": "garage-integ-test", "path": "/check", }) ); + // FIXME: Edge case with empty domain + // Currently, empty domain is interpreted as an absent parameter + // due to logic in router_macros.rs, so this test fails. + // Maybe we want empty parameters to be acceptable? But that might + // break a lot of S3 stuff. + /* let admin_req = || { Request::builder() .method("GET") @@ -456,6 +462,7 @@ async fn test_website_check_domain() { "path": "/check", }) ); + */ let admin_req = || { Request::builder() From ba810b2e8157855df36b5f8dc9d5fced40efbafd Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 18:51:15 +0100 Subject: [PATCH 024/192] admin api: rename bucket aliasing operations --- doc/api/garage-admin-v2.yml | 16 +++++++-------- doc/drafts/admin-api.md | 8 ++++---- src/api/admin/api.rs | 32 ++++++++++++++--------------- src/api/admin/bucket.rs | 40 ++++++++++++++++++------------------- src/api/admin/router_v2.rs | 20 +++++++++---------- 5 files changed, 58 insertions(+), 58 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 652f8b39..07df11ad 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -946,11 +946,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /GlobalAliasBucket: + /AddGlobalBucketAlias: post: tags: - Bucket aliases - operationId: "GlobalAliasBucket" + operationId: "AddGlobalBucketAlias" summary: "Add a global alias" description: | Add a global alias to the target bucket @@ -982,11 +982,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /GlobalUnaliasBucket: + /RemoveGlobalBucketAlias: post: tags: - Bucket aliases - operationId: "GlobalUnaliasBucket" + operationId: "RemoveGlobalBucketAlias" summary: "Delete a global alias" description: | Delete a global alias from the target bucket @@ -1018,11 +1018,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /LocalAliasBucket: + /AddLocalBucketAlias: post: tags: - Bucket aliases - operationId: "LocalAliasBucket" + operationId: "AddLocalBucketAlias" summary: "Add a local alias" description: | Add a local alias, bound to specified account, to the target bucket @@ -1057,11 +1057,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /LocalUnaliasBucket: + /RemoveGlobalBucketAlias: post: tags: - Bucket aliases - operationId: "LocalUnaliasBucket" + operationId: "RemoveGlobalBucketAlias" summary: "Delete a local alias" description: | Delete a local alias, bound to specified account, from the target bucket diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index 92b6a6db..6833f251 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -750,7 +750,7 @@ Other flags will remain unchanged. ### Operations on bucket aliases -#### GlobalAliasBucket `POST /v2/GlobalAliasBucket` +#### AddGlobalBucketAlias `POST /v2/AddGlobalBucketAlias` Creates a global alias for a bucket. @@ -763,7 +763,7 @@ Request body format: } ``` -#### GlobalUnaliasBucket `POST /v2/GlobalUnaliasBucket` +#### RemoveGlobalBucketAlias `POST /v2/RemoveGlobalBucketAlias` Removes a global alias for a bucket. @@ -776,7 +776,7 @@ Request body format: } ``` -#### LocalAliasBucket `POST /v2/LocalAliasBucket` +#### AddLocalBucketAlias `POST /v2/AddLocalBucketAlias` Creates a local alias for a bucket in the namespace of a specific access key. @@ -790,7 +790,7 @@ Request body format: } ``` -#### LocalUnaliasBucket `POST /v2/LocalUnaliasBucket` +#### RemoveLocalBucketAlias `POST /v2/RemoveLocalBucketAlias` Removes a local alias for a bucket in the namespace of a specific access key. diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 01b4f928..632711d1 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -54,10 +54,10 @@ admin_endpoints![ BucketDenyKey, // Operations on bucket aliases - GlobalAliasBucket, - GlobalUnaliasBucket, - LocalAliasBucket, - LocalUnaliasBucket, + AddGlobalBucketAlias, + RemoveGlobalBucketAlias, + AddLocalBucketAlias, + RemoveLocalBucketAlias, ]; // ********************************************** @@ -514,48 +514,48 @@ pub struct BucketDenyKeyResponse(pub GetBucketInfoResponse); // Operations on bucket aliases // ********************************************** -// ---- GlobalAliasBucket ---- +// ---- AddGlobalBucketAlias ---- #[derive(Serialize, Deserialize)] -pub struct GlobalAliasBucketRequest { +pub struct AddGlobalBucketAliasRequest { pub bucket_id: String, pub alias: String, } #[derive(Serialize, Deserialize)] -pub struct GlobalAliasBucketResponse(pub GetBucketInfoResponse); +pub struct AddGlobalBucketAliasResponse(pub GetBucketInfoResponse); -// ---- GlobalUnaliasBucket ---- +// ---- RemoveGlobalBucketAlias ---- #[derive(Serialize, Deserialize)] -pub struct GlobalUnaliasBucketRequest { +pub struct RemoveGlobalBucketAliasRequest { pub bucket_id: String, pub alias: String, } #[derive(Serialize, Deserialize)] -pub struct GlobalUnaliasBucketResponse(pub GetBucketInfoResponse); +pub struct RemoveGlobalBucketAliasResponse(pub GetBucketInfoResponse); -// ---- LocalAliasBucket ---- +// ---- AddLocalBucketAlias ---- #[derive(Serialize, Deserialize)] -pub struct LocalAliasBucketRequest { +pub struct AddLocalBucketAliasRequest { pub bucket_id: String, pub access_key_id: String, pub alias: String, } #[derive(Serialize, Deserialize)] -pub struct LocalAliasBucketResponse(pub GetBucketInfoResponse); +pub struct AddLocalBucketAliasResponse(pub GetBucketInfoResponse); -// ---- LocalUnaliasBucket ---- +// ---- RemoveLocalBucketAlias ---- #[derive(Serialize, Deserialize)] -pub struct LocalUnaliasBucketRequest { +pub struct RemoveLocalBucketAliasRequest { pub bucket_id: String, pub access_key_id: String, pub alias: String, } #[derive(Serialize, Deserialize)] -pub struct LocalUnaliasBucketResponse(pub GetBucketInfoResponse); +pub struct RemoveLocalBucketAliasResponse(pub GetBucketInfoResponse); diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 8e19b93e..09952bff 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -22,10 +22,10 @@ use crate::admin::api::{ BucketDenyKeyResponse, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, CreateBucketResponse, DeleteBucketRequest, DeleteBucketResponse, GetBucketInfoKey, GetBucketInfoRequest, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, - GlobalAliasBucketRequest, GlobalAliasBucketResponse, GlobalUnaliasBucketRequest, - GlobalUnaliasBucketResponse, ListBucketsRequest, ListBucketsResponse, ListBucketsResponseItem, - LocalAliasBucketRequest, LocalAliasBucketResponse, LocalUnaliasBucketRequest, - LocalUnaliasBucketResponse, UpdateBucketRequest, UpdateBucketResponse, + AddGlobalBucketAliasRequest, AddGlobalBucketAliasResponse, RemoveGlobalBucketAliasRequest, + RemoveGlobalBucketAliasResponse, ListBucketsRequest, ListBucketsResponse, ListBucketsResponseItem, + AddLocalBucketAliasRequest, AddLocalBucketAliasResponse, RemoveLocalBucketAliasRequest, + RemoveLocalBucketAliasResponse, UpdateBucketRequest, UpdateBucketResponse, }; use crate::admin::error::*; use crate::admin::EndpointHandler; @@ -453,10 +453,10 @@ pub async fn handle_bucket_change_key_perm( // ---- BUCKET ALIASES ---- #[async_trait] -impl EndpointHandler for GlobalAliasBucketRequest { - type Response = GlobalAliasBucketResponse; +impl EndpointHandler for AddGlobalBucketAliasRequest { + type Response = AddGlobalBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -465,17 +465,17 @@ impl EndpointHandler for GlobalAliasBucketRequest { .set_global_bucket_alias(bucket_id, &self.alias) .await?; - Ok(GlobalAliasBucketResponse( + Ok(AddGlobalBucketAliasResponse( bucket_info_results(garage, bucket_id).await?, )) } } #[async_trait] -impl EndpointHandler for GlobalUnaliasBucketRequest { - type Response = GlobalUnaliasBucketResponse; +impl EndpointHandler for RemoveGlobalBucketAliasRequest { + type Response = RemoveGlobalBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -484,17 +484,17 @@ impl EndpointHandler for GlobalUnaliasBucketRequest { .unset_global_bucket_alias(bucket_id, &self.alias) .await?; - Ok(GlobalUnaliasBucketResponse( + Ok(RemoveGlobalBucketAliasResponse( bucket_info_results(garage, bucket_id).await?, )) } } #[async_trait] -impl EndpointHandler for LocalAliasBucketRequest { - type Response = LocalAliasBucketResponse; +impl EndpointHandler for AddLocalBucketAliasRequest { + type Response = AddLocalBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -503,17 +503,17 @@ impl EndpointHandler for LocalAliasBucketRequest { .set_local_bucket_alias(bucket_id, &self.access_key_id, &self.alias) .await?; - Ok(LocalAliasBucketResponse( + Ok(AddLocalBucketAliasResponse( bucket_info_results(garage, bucket_id).await?, )) } } #[async_trait] -impl EndpointHandler for LocalUnaliasBucketRequest { - type Response = LocalUnaliasBucketResponse; +impl EndpointHandler for RemoveLocalBucketAliasRequest { + type Response = RemoveLocalBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -522,7 +522,7 @@ impl EndpointHandler for LocalUnaliasBucketRequest { .unset_local_bucket_alias(bucket_id, &self.access_key_id, &self.alias) .await?; - Ok(LocalUnaliasBucketResponse( + Ok(RemoveLocalBucketAliasResponse( bucket_info_results(garage, bucket_id).await?, )) } diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index c7a5e316..6faa2ab1 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -55,10 +55,10 @@ impl AdminApiRequest { POST BucketAllowKey (body), POST BucketDenyKey (body), // Bucket aliases - POST GlobalAliasBucket (body), - POST GlobalUnaliasBucket (body), - POST LocalAliasBucket (body), - POST LocalUnaliasBucket (body), + POST AddGlobalBucketAlias (body), + POST RemoveGlobalBucketAlias (body), + POST AddLocalBucketAlias (body), + POST RemoveLocalBucketAlias (body), ]); if let Some(message) = query.nonempty_message() { @@ -174,14 +174,14 @@ impl AdminApiRequest { Ok(AdminApiRequest::BucketDenyKey(BucketDenyKeyRequest(req))) } // Bucket aliasing - Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::GlobalAliasBucket( - GlobalAliasBucketRequest { + Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::AddGlobalBucketAlias( + AddGlobalBucketAliasRequest { bucket_id: id, alias, }, )), Endpoint::GlobalUnaliasBucket { id, alias } => Ok( - AdminApiRequest::GlobalUnaliasBucket(GlobalUnaliasBucketRequest { + AdminApiRequest::RemoveGlobalBucketAlias(RemoveGlobalBucketAliasRequest { bucket_id: id, alias, }), @@ -190,7 +190,7 @@ impl AdminApiRequest { id, access_key_id, alias, - } => Ok(AdminApiRequest::LocalAliasBucket(LocalAliasBucketRequest { + } => Ok(AdminApiRequest::AddLocalBucketAlias(AddLocalBucketAliasRequest { access_key_id, bucket_id: id, alias, @@ -199,8 +199,8 @@ impl AdminApiRequest { id, access_key_id, alias, - } => Ok(AdminApiRequest::LocalUnaliasBucket( - LocalUnaliasBucketRequest { + } => Ok(AdminApiRequest::RemoveLocalBucketAlias( + RemoveLocalBucketAliasRequest { access_key_id, bucket_id: id, alias, From 5fefbd94e9f8cded0d911f7cdae3d0382762607c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 18:53:44 +0100 Subject: [PATCH 025/192] admin api: rename allow/deny api calls in api v2 --- doc/api/garage-admin-v2.yml | 8 ++++---- doc/drafts/admin-api.md | 4 ++-- src/api/admin/api.rs | 16 ++++++++-------- src/api/admin/bucket.rs | 20 ++++++++++---------- src/api/admin/router_v2.rs | 8 ++++---- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 07df11ad..9ee1cf63 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -826,11 +826,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /BucketAllowKey: + /AllowBucketKey: post: tags: - Permissions - operationId: "BucketAllowKey" + operationId: "AllowBucketKey" summary: "Allow key" description: | ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. @@ -886,11 +886,11 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /BucketDenyKey: + /DenyBucketKey: post: tags: - Permissions - operationId: "BucketDenyKey" + operationId: "DenyBucketKey" summary: "Deny key" description: | ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index 6833f251..1fbe7c40 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -705,7 +705,7 @@ Warning: this will delete all aliases associated with the bucket! ### Operations on permissions for keys on buckets -#### BucketAllowKey `POST /v2/BucketAllowKey` +#### AllowBucketKey `POST /v2/AllowBucketKey` Allows a key to do read/write/owner operations on a bucket. @@ -726,7 +726,7 @@ Request body format: Flags in `permissions` which have the value `true` will be activated. Other flags will remain unchanged. -#### BucketDenyKey `POST /v2/BucketDenyKey` +#### DenyBucketKey `POST /v2/DenyBucketKey` Denies a key from doing read/write/owner operations on a bucket. diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 632711d1..c3559587 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -50,8 +50,8 @@ admin_endpoints![ DeleteBucket, // Operations on permissions for keys on buckets - BucketAllowKey, - BucketDenyKey, + AllowBucketKey, + DenyBucketKey, // Operations on bucket aliases AddGlobalBucketAlias, @@ -486,13 +486,13 @@ pub struct DeleteBucketResponse; // Operations on permissions for keys on buckets // ********************************************** -// ---- BucketAllowKey ---- +// ---- AllowBucketKey ---- #[derive(Serialize, Deserialize)] -pub struct BucketAllowKeyRequest(pub BucketKeyPermChangeRequest); +pub struct AllowBucketKeyRequest(pub BucketKeyPermChangeRequest); #[derive(Serialize, Deserialize)] -pub struct BucketAllowKeyResponse(pub GetBucketInfoResponse); +pub struct AllowBucketKeyResponse(pub GetBucketInfoResponse); #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -502,13 +502,13 @@ pub struct BucketKeyPermChangeRequest { pub permissions: ApiBucketKeyPerm, } -// ---- BucketDenyKey ---- +// ---- DenyBucketKey ---- #[derive(Serialize, Deserialize)] -pub struct BucketDenyKeyRequest(pub BucketKeyPermChangeRequest); +pub struct DenyBucketKeyRequest(pub BucketKeyPermChangeRequest); #[derive(Serialize, Deserialize)] -pub struct BucketDenyKeyResponse(pub GetBucketInfoResponse); +pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // ********************************************** // Operations on bucket aliases diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 09952bff..885c1749 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -18,8 +18,8 @@ use garage_model::s3::object_table::*; use crate::admin::api::ApiBucketKeyPerm; use crate::admin::api::{ - ApiBucketQuotas, BucketAllowKeyRequest, BucketAllowKeyResponse, BucketDenyKeyRequest, - BucketDenyKeyResponse, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, + ApiBucketQuotas, AllowBucketKeyRequest, AllowBucketKeyResponse, DenyBucketKeyRequest, + DenyBucketKeyResponse, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, CreateBucketResponse, DeleteBucketRequest, DeleteBucketResponse, GetBucketInfoKey, GetBucketInfoRequest, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, AddGlobalBucketAliasRequest, AddGlobalBucketAliasResponse, RemoveGlobalBucketAliasRequest, @@ -394,22 +394,22 @@ impl EndpointHandler for UpdateBucketRequest { // ---- BUCKET/KEY PERMISSIONS ---- #[async_trait] -impl EndpointHandler for BucketAllowKeyRequest { - type Response = BucketAllowKeyResponse; +impl EndpointHandler for AllowBucketKeyRequest { + type Response = AllowBucketKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let res = handle_bucket_change_key_perm(garage, self.0, true).await?; - Ok(BucketAllowKeyResponse(res)) + Ok(AllowBucketKeyResponse(res)) } } #[async_trait] -impl EndpointHandler for BucketDenyKeyRequest { - type Response = BucketDenyKeyResponse; +impl EndpointHandler for DenyBucketKeyRequest { + type Response = DenyBucketKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let res = handle_bucket_change_key_perm(garage, self.0, false).await?; - Ok(BucketDenyKeyResponse(res)) + Ok(DenyBucketKeyResponse(res)) } } diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 6faa2ab1..45613ea4 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -52,8 +52,8 @@ impl AdminApiRequest { POST DeleteBucket (query::id), POST UpdateBucket (body_field, query::id), // Bucket-key permissions - POST BucketAllowKey (body), - POST BucketDenyKey (body), + POST AllowBucketKey (body), + POST DenyBucketKey (body), // Bucket aliases POST AddGlobalBucketAlias (body), POST RemoveGlobalBucketAlias (body), @@ -167,11 +167,11 @@ impl AdminApiRequest { // Bucket-key permissions Endpoint::BucketAllowKey => { let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::BucketAllowKey(BucketAllowKeyRequest(req))) + Ok(AdminApiRequest::AllowBucketKey(AllowBucketKeyRequest(req))) } Endpoint::BucketDenyKey => { let req = parse_json_body::(req).await?; - Ok(AdminApiRequest::BucketDenyKey(BucketDenyKeyRequest(req))) + Ok(AdminApiRequest::DenyBucketKey(DenyBucketKeyRequest(req))) } // Bucket aliasing Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::AddGlobalBucketAlias( From 12ea4cda5fe033fc2b9f1fec51ddc3d8b860a85f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 28 Jan 2025 19:03:39 +0100 Subject: [PATCH 026/192] admin api: merge calls to manage global/local aliases --- doc/api/garage-admin-v2.yml | 94 +++++------------------------------ doc/drafts/admin-api.md | 38 +++----------- src/api/admin/api.rs | 44 ++++------------- src/api/admin/bucket.rs | 98 ++++++++++++++----------------------- src/api/admin/router_v2.rs | 34 ++++++------- 5 files changed, 86 insertions(+), 222 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 9ee1cf63..5cca7dd1 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -946,14 +946,16 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /AddGlobalBucketAlias: + /AddBucketAlias: post: tags: - Bucket aliases - operationId: "AddGlobalBucketAlias" - summary: "Add a global alias" + operationId: "AddlBucketAlias" + summary: "Add an alias to a bucket" description: | - Add a global alias to the target bucket + Add an alias for the target bucket. + This can be a local alias if `accessKeyId` is specified, + or a global alias otherwise. requestBody: required: true content: @@ -961,78 +963,6 @@ paths: schema: type: object required: [bucketId, alias] - properties: - bucketId: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - alias: - type: string - example: my_documents - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your request body" - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /RemoveGlobalBucketAlias: - post: - tags: - - Bucket aliases - operationId: "RemoveGlobalBucketAlias" - summary: "Delete a global alias" - description: | - Delete a global alias from the target bucket - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [bucketId, alias] - properties: - bucketId: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - alias: - type: string - example: my_documents - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your request body" - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /AddLocalBucketAlias: - post: - tags: - - Bucket aliases - operationId: "AddLocalBucketAlias" - summary: "Add a local alias" - description: | - Add a local alias, bound to specified account, to the target bucket - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [bucketId, accessKeyId, alias] properties: bucketId: type: string @@ -1057,21 +987,23 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' - /RemoveGlobalBucketAlias: + /RemoveBucketAlias: post: tags: - Bucket aliases - operationId: "RemoveGlobalBucketAlias" - summary: "Delete a local alias" + operationId: "RemoveBucketAlias" + summary: "Remove an alias from a bucket" description: | - Delete a local alias, bound to specified account, from the target bucket + Remove an alias for the target bucket. + This can be a local alias if `accessKeyId` is specified, + or a global alias otherwise. requestBody: required: true content: application/json: schema: type: object - required: [bucketId, accessKeyId, alias] + required: [bucketId, alias] properties: bucketId: type: string diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index 1fbe7c40..6d24a1b6 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -750,35 +750,11 @@ Other flags will remain unchanged. ### Operations on bucket aliases -#### AddGlobalBucketAlias `POST /v2/AddGlobalBucketAlias` +#### AddBucketAlias `POST /v2/AddBucketAlias` -Creates a global alias for a bucket. - -Request body format: - -```json -{ - "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", - "alias": "the-bucket" -} -``` - -#### RemoveGlobalBucketAlias `POST /v2/RemoveGlobalBucketAlias` - -Removes a global alias for a bucket. - -Request body format: - -```json -{ - "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", - "alias": "the-bucket" -} -``` - -#### AddLocalBucketAlias `POST /v2/AddLocalBucketAlias` - -Creates a local alias for a bucket in the namespace of a specific access key. +Creates an alias for a bucket in the namespace of a specific access key. +If `accessKeyId` is specified, an alias is created in the local namespace +of the key. Otherwise, a global alias is created. Request body format: @@ -790,9 +766,11 @@ Request body format: } ``` -#### RemoveLocalBucketAlias `POST /v2/RemoveLocalBucketAlias` +#### RemoveBucketAlias `POST /v2/RemoveBucketAlias` -Removes a local alias for a bucket in the namespace of a specific access key. +Removes an alias for a bucket in the namespace of a specific access key. +If `accessKeyId` is specified, the alias is removed from the local namespace +of the key. Otherwise, the alias is removed from the global namespace. Request body format: diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index c3559587..5fedd11f 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -54,10 +54,8 @@ admin_endpoints![ DenyBucketKey, // Operations on bucket aliases - AddGlobalBucketAlias, - RemoveGlobalBucketAlias, - AddLocalBucketAlias, - RemoveLocalBucketAlias, + AddBucketAlias, + RemoveBucketAlias, ]; // ********************************************** @@ -514,48 +512,26 @@ pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // Operations on bucket aliases // ********************************************** -// ---- AddGlobalBucketAlias ---- +// ---- AddBucketAlias ---- #[derive(Serialize, Deserialize)] -pub struct AddGlobalBucketAliasRequest { +pub struct AddBucketAliasRequest { pub bucket_id: String, + pub access_key_id: Option, pub alias: String, } #[derive(Serialize, Deserialize)] -pub struct AddGlobalBucketAliasResponse(pub GetBucketInfoResponse); +pub struct AddBucketAliasResponse(pub GetBucketInfoResponse); -// ---- RemoveGlobalBucketAlias ---- +// ---- RemoveBucketAlias ---- #[derive(Serialize, Deserialize)] -pub struct RemoveGlobalBucketAliasRequest { +pub struct RemoveBucketAliasRequest { pub bucket_id: String, + pub access_key_id: Option, pub alias: String, } #[derive(Serialize, Deserialize)] -pub struct RemoveGlobalBucketAliasResponse(pub GetBucketInfoResponse); - -// ---- AddLocalBucketAlias ---- - -#[derive(Serialize, Deserialize)] -pub struct AddLocalBucketAliasRequest { - pub bucket_id: String, - pub access_key_id: String, - pub alias: String, -} - -#[derive(Serialize, Deserialize)] -pub struct AddLocalBucketAliasResponse(pub GetBucketInfoResponse); - -// ---- RemoveLocalBucketAlias ---- - -#[derive(Serialize, Deserialize)] -pub struct RemoveLocalBucketAliasRequest { - pub bucket_id: String, - pub access_key_id: String, - pub alias: String, -} - -#[derive(Serialize, Deserialize)] -pub struct RemoveLocalBucketAliasResponse(pub GetBucketInfoResponse); +pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 885c1749..ee7a5e12 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -18,14 +18,12 @@ use garage_model::s3::object_table::*; use crate::admin::api::ApiBucketKeyPerm; use crate::admin::api::{ - ApiBucketQuotas, AllowBucketKeyRequest, AllowBucketKeyResponse, DenyBucketKeyRequest, - DenyBucketKeyResponse, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, - CreateBucketResponse, DeleteBucketRequest, DeleteBucketResponse, GetBucketInfoKey, - GetBucketInfoRequest, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, - AddGlobalBucketAliasRequest, AddGlobalBucketAliasResponse, RemoveGlobalBucketAliasRequest, - RemoveGlobalBucketAliasResponse, ListBucketsRequest, ListBucketsResponse, ListBucketsResponseItem, - AddLocalBucketAliasRequest, AddLocalBucketAliasResponse, RemoveLocalBucketAliasRequest, - RemoveLocalBucketAliasResponse, UpdateBucketRequest, UpdateBucketResponse, + AddBucketAliasRequest, AddBucketAliasResponse, AllowBucketKeyRequest, AllowBucketKeyResponse, + ApiBucketQuotas, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, + CreateBucketResponse, DeleteBucketRequest, DeleteBucketResponse, DenyBucketKeyRequest, + DenyBucketKeyResponse, GetBucketInfoKey, GetBucketInfoRequest, GetBucketInfoResponse, + GetBucketInfoWebsiteResponse, ListBucketsRequest, ListBucketsResponse, ListBucketsResponseItem, + RemoveBucketAliasRequest, RemoveBucketAliasResponse, UpdateBucketRequest, UpdateBucketResponse, }; use crate::admin::error::*; use crate::admin::EndpointHandler; @@ -453,76 +451,56 @@ pub async fn handle_bucket_change_key_perm( // ---- BUCKET ALIASES ---- #[async_trait] -impl EndpointHandler for AddGlobalBucketAliasRequest { - type Response = AddGlobalBucketAliasResponse; +impl EndpointHandler for AddBucketAliasRequest { + type Response = AddBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; - helper - .set_global_bucket_alias(bucket_id, &self.alias) - .await?; + match self.access_key_id { + None => { + helper + .set_global_bucket_alias(bucket_id, &self.alias) + .await?; + } + Some(ak) => { + helper + .set_local_bucket_alias(bucket_id, &ak, &self.alias) + .await?; + } + } - Ok(AddGlobalBucketAliasResponse( + Ok(AddBucketAliasResponse( bucket_info_results(garage, bucket_id).await?, )) } } #[async_trait] -impl EndpointHandler for RemoveGlobalBucketAliasRequest { - type Response = RemoveGlobalBucketAliasResponse; +impl EndpointHandler for RemoveBucketAliasRequest { + type Response = RemoveBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; - helper - .unset_global_bucket_alias(bucket_id, &self.alias) - .await?; + match self.access_key_id { + None => { + helper + .unset_global_bucket_alias(bucket_id, &self.alias) + .await?; + } + Some(ak) => { + helper + .unset_local_bucket_alias(bucket_id, &ak, &self.alias) + .await?; + } + } - Ok(RemoveGlobalBucketAliasResponse( - bucket_info_results(garage, bucket_id).await?, - )) - } -} - -#[async_trait] -impl EndpointHandler for AddLocalBucketAliasRequest { - type Response = AddLocalBucketAliasResponse; - - async fn handle(self, garage: &Arc) -> Result { - let bucket_id = parse_bucket_id(&self.bucket_id)?; - - let helper = garage.locked_helper().await; - - helper - .set_local_bucket_alias(bucket_id, &self.access_key_id, &self.alias) - .await?; - - Ok(AddLocalBucketAliasResponse( - bucket_info_results(garage, bucket_id).await?, - )) - } -} - -#[async_trait] -impl EndpointHandler for RemoveLocalBucketAliasRequest { - type Response = RemoveLocalBucketAliasResponse; - - async fn handle(self, garage: &Arc) -> Result { - let bucket_id = parse_bucket_id(&self.bucket_id)?; - - let helper = garage.locked_helper().await; - - helper - .unset_local_bucket_alias(bucket_id, &self.access_key_id, &self.alias) - .await?; - - Ok(RemoveLocalBucketAliasResponse( + Ok(RemoveBucketAliasResponse( bucket_info_results(garage, bucket_id).await?, )) } diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 45613ea4..a6f110a7 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -55,10 +55,8 @@ impl AdminApiRequest { POST AllowBucketKey (body), POST DenyBucketKey (body), // Bucket aliases - POST AddGlobalBucketAlias (body), - POST RemoveGlobalBucketAlias (body), - POST AddLocalBucketAlias (body), - POST RemoveLocalBucketAlias (body), + POST AddBucketAlias (body), + POST RemoveBucketAlias (body), ]); if let Some(message) = query.nonempty_message() { @@ -174,24 +172,26 @@ impl AdminApiRequest { Ok(AdminApiRequest::DenyBucketKey(DenyBucketKeyRequest(req))) } // Bucket aliasing - Endpoint::GlobalAliasBucket { id, alias } => Ok(AdminApiRequest::AddGlobalBucketAlias( - AddGlobalBucketAliasRequest { + Endpoint::GlobalAliasBucket { id, alias } => { + Ok(AdminApiRequest::AddBucketAlias(AddBucketAliasRequest { + access_key_id: None, + bucket_id: id, + alias, + })) + } + Endpoint::GlobalUnaliasBucket { id, alias } => Ok(AdminApiRequest::RemoveBucketAlias( + RemoveBucketAliasRequest { + access_key_id: None, bucket_id: id, alias, }, )), - Endpoint::GlobalUnaliasBucket { id, alias } => Ok( - AdminApiRequest::RemoveGlobalBucketAlias(RemoveGlobalBucketAliasRequest { - bucket_id: id, - alias, - }), - ), Endpoint::LocalAliasBucket { id, access_key_id, alias, - } => Ok(AdminApiRequest::AddLocalBucketAlias(AddLocalBucketAliasRequest { - access_key_id, + } => Ok(AdminApiRequest::AddBucketAlias(AddBucketAliasRequest { + access_key_id: Some(access_key_id), bucket_id: id, alias, })), @@ -199,9 +199,9 @@ impl AdminApiRequest { id, access_key_id, alias, - } => Ok(AdminApiRequest::RemoveLocalBucketAlias( - RemoveLocalBucketAliasRequest { - access_key_id, + } => Ok(AdminApiRequest::RemoveBucketAlias( + RemoveBucketAliasRequest { + access_key_id: Some(access_key_id), bucket_id: id, alias, }, From 420bbc162dffd1246544168cf2e935efc60c5c98 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 29 Jan 2025 11:06:45 +0100 Subject: [PATCH 027/192] admin api: clearer syntax for AddBucketAlias and RemoveBucketAlias --- doc/api/garage-admin-v2.yml | 23 +++++++++++++---------- doc/drafts/admin-api.md | 30 +++++++++++++++--------------- src/api/admin/api.rs | 22 ++++++++++++++++++---- src/api/admin/bucket.rs | 36 +++++++++++++++++------------------- src/api/admin/cluster.rs | 9 +-------- src/api/admin/key.rs | 7 +------ src/api/admin/router_v2.rs | 22 ++++++++++++++-------- 7 files changed, 79 insertions(+), 70 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 5cca7dd1..0b948135 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -950,7 +950,7 @@ paths: post: tags: - Bucket aliases - operationId: "AddlBucketAlias" + operationId: "AddBucketAlias" summary: "Add an alias to a bucket" description: | Add an alias for the target bucket. @@ -962,17 +962,19 @@ paths: application/json: schema: type: object - required: [bucketId, alias] + required: [bucketId] properties: bucketId: type: string example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + globalAlias: + type: string + localAlias: + type: string + example: my_documents accessKeyId: type: string example: GK31c2f218a2e44f485b94239e - alias: - type: string - example: my_documents responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." @@ -1003,17 +1005,18 @@ paths: application/json: schema: type: object - required: [bucketId, alias] + required: [bucketId] properties: bucketId: type: string example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b + globalAlias: + type: string + example: the_bucket + localAlias: + type: string accessKeyId: type: string - example: GK31c2f218a2e44f485b94239e - alias: - type: string - example: my_documents responses: '500': description: "The server can not handle your request. Check your connectivity with the rest of the cluster." diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index 6d24a1b6..ca60ead1 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -753,32 +753,32 @@ Other flags will remain unchanged. #### AddBucketAlias `POST /v2/AddBucketAlias` Creates an alias for a bucket in the namespace of a specific access key. -If `accessKeyId` is specified, an alias is created in the local namespace -of the key. Otherwise, a global alias is created. +To create a global alias, specify the `globalAlias` field. +To create a local alias, specify the `localAlias` and `accessKeyId` fields. Request body format: +```json +{ + "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", + "globalAlias": "my-bucket" +} +``` + +or: + ```json { "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", "accessKeyId": "GK31c2f218a2e44f485b94239e", - "alias": "my-bucket" + "localAlias": "my-bucket" } ``` #### RemoveBucketAlias `POST /v2/RemoveBucketAlias` Removes an alias for a bucket in the namespace of a specific access key. -If `accessKeyId` is specified, the alias is removed from the local namespace -of the key. Otherwise, the alias is removed from the global namespace. - -Request body format: - -```json -{ - "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", - "accessKeyId": "GK31c2f218a2e44f485b94239e", - "alias": "my-bucket" -} -``` +To remove a global alias, specify the `globalAlias` field. +To remove a local alias, specify the `localAlias` and `accessKeyId` fields. +Request body format: same as AddBucketAlias. diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 5fedd11f..eac93b6e 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -515,22 +515,36 @@ pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // ---- AddBucketAlias ---- #[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AddBucketAliasRequest { pub bucket_id: String, - pub access_key_id: Option, - pub alias: String, + #[serde(flatten)] + pub alias: BucketAliasEnum, } #[derive(Serialize, Deserialize)] pub struct AddBucketAliasResponse(pub GetBucketInfoResponse); +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum BucketAliasEnum { + #[serde(rename_all = "camelCase")] + Global { global_alias: String }, + #[serde(rename_all = "camelCase")] + Local { + local_alias: String, + access_key_id: String, + }, +} + // ---- RemoveBucketAlias ---- #[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct RemoveBucketAliasRequest { pub bucket_id: String, - pub access_key_id: Option, - pub alias: String, + #[serde(flatten)] + pub alias: BucketAliasEnum, } #[derive(Serialize, Deserialize)] diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ee7a5e12..0cc420ec 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -16,15 +16,7 @@ use garage_model::permission::*; use garage_model::s3::mpu_table; use garage_model::s3::object_table::*; -use crate::admin::api::ApiBucketKeyPerm; -use crate::admin::api::{ - AddBucketAliasRequest, AddBucketAliasResponse, AllowBucketKeyRequest, AllowBucketKeyResponse, - ApiBucketQuotas, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest, - CreateBucketResponse, DeleteBucketRequest, DeleteBucketResponse, DenyBucketKeyRequest, - DenyBucketKeyResponse, GetBucketInfoKey, GetBucketInfoRequest, GetBucketInfoResponse, - GetBucketInfoWebsiteResponse, ListBucketsRequest, ListBucketsResponse, ListBucketsResponseItem, - RemoveBucketAliasRequest, RemoveBucketAliasResponse, UpdateBucketRequest, UpdateBucketResponse, -}; +use crate::admin::api::*; use crate::admin::error::*; use crate::admin::EndpointHandler; use crate::common_error::CommonError; @@ -459,15 +451,18 @@ impl EndpointHandler for AddBucketAliasRequest { let helper = garage.locked_helper().await; - match self.access_key_id { - None => { + match self.alias { + BucketAliasEnum::Global { global_alias } => { helper - .set_global_bucket_alias(bucket_id, &self.alias) + .set_global_bucket_alias(bucket_id, &global_alias) .await?; } - Some(ak) => { + BucketAliasEnum::Local { + local_alias, + access_key_id, + } => { helper - .set_local_bucket_alias(bucket_id, &ak, &self.alias) + .set_local_bucket_alias(bucket_id, &access_key_id, &local_alias) .await?; } } @@ -487,15 +482,18 @@ impl EndpointHandler for RemoveBucketAliasRequest { let helper = garage.locked_helper().await; - match self.access_key_id { - None => { + match self.alias { + BucketAliasEnum::Global { global_alias } => { helper - .unset_global_bucket_alias(bucket_id, &self.alias) + .unset_global_bucket_alias(bucket_id, &global_alias) .await?; } - Some(ak) => { + BucketAliasEnum::Local { + local_alias, + access_key_id, + } => { helper - .unset_local_bucket_alias(bucket_id, &ak, &self.alias) + .unset_local_bucket_alias(bucket_id, &access_key_id, &local_alias) .await?; } } diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 3327cb4c..112cb542 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -10,14 +10,7 @@ use garage_rpc::layout; use garage_model::garage::Garage; -use crate::admin::api::{ - ApplyClusterLayoutRequest, ApplyClusterLayoutResponse, ConnectClusterNodeResponse, - ConnectClusterNodesRequest, ConnectClusterNodesResponse, FreeSpaceResp, - GetClusterHealthRequest, GetClusterHealthResponse, GetClusterLayoutRequest, - GetClusterLayoutResponse, GetClusterStatusRequest, GetClusterStatusResponse, NodeResp, - NodeRoleChange, NodeRoleChangeEnum, NodeRoleResp, RevertClusterLayoutRequest, - RevertClusterLayoutResponse, UpdateClusterLayoutRequest, UpdateClusterLayoutResponse, -}; +use crate::admin::api::*; use crate::admin::error::*; use crate::admin::EndpointHandler; diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 5bec2202..3e4201d9 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -8,12 +8,7 @@ use garage_table::*; use garage_model::garage::Garage; use garage_model::key_table::*; -use crate::admin::api::{ - ApiBucketKeyPerm, CreateKeyRequest, CreateKeyResponse, DeleteKeyRequest, DeleteKeyResponse, - GetKeyInfoRequest, GetKeyInfoResponse, ImportKeyRequest, ImportKeyResponse, - KeyInfoBucketResponse, KeyPerm, ListKeysRequest, ListKeysResponse, ListKeysResponseItem, - UpdateKeyRequest, UpdateKeyResponse, -}; +use crate::admin::api::*; use crate::admin::error::*; use crate::admin::EndpointHandler; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index a6f110a7..29250f39 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -174,16 +174,18 @@ impl AdminApiRequest { // Bucket aliasing Endpoint::GlobalAliasBucket { id, alias } => { Ok(AdminApiRequest::AddBucketAlias(AddBucketAliasRequest { - access_key_id: None, bucket_id: id, - alias, + alias: BucketAliasEnum::Global { + global_alias: alias, + }, })) } Endpoint::GlobalUnaliasBucket { id, alias } => Ok(AdminApiRequest::RemoveBucketAlias( RemoveBucketAliasRequest { - access_key_id: None, bucket_id: id, - alias, + alias: BucketAliasEnum::Global { + global_alias: alias, + }, }, )), Endpoint::LocalAliasBucket { @@ -191,9 +193,11 @@ impl AdminApiRequest { access_key_id, alias, } => Ok(AdminApiRequest::AddBucketAlias(AddBucketAliasRequest { - access_key_id: Some(access_key_id), bucket_id: id, - alias, + alias: BucketAliasEnum::Local { + local_alias: alias, + access_key_id, + }, })), Endpoint::LocalUnaliasBucket { id, @@ -201,9 +205,11 @@ impl AdminApiRequest { alias, } => Ok(AdminApiRequest::RemoveBucketAlias( RemoveBucketAliasRequest { - access_key_id: Some(access_key_id), bucket_id: id, - alias, + alias: BucketAliasEnum::Local { + local_alias: alias, + access_key_id, + }, }, )), From 4f0b923c4f2bc9be80bf1e7ca61cc66c354cc7e0 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 29 Jan 2025 12:06:58 +0100 Subject: [PATCH 028/192] admin api: small fixes --- doc/api/garage-admin-v2.yml | 2 +- doc/drafts/admin-api.md | 2 +- src/api/admin/api.rs | 22 ++++++++++++++++++---- src/api/admin/api_server.rs | 2 +- src/api/admin/cluster.rs | 4 ++-- src/api/admin/macros.rs | 19 ++++++++++++++++++- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 0b948135..725c1d01 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -91,7 +91,7 @@ paths: example: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" garageVersion: type: string - example: "v0.9.0" + example: "v2.0.0" garageFeatures: type: array items: diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index ca60ead1..eb327307 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -53,7 +53,7 @@ Returns an HTTP status 200 if the node is ready to answer user's requests, and an HTTP status 503 (Service Unavailable) if there are some partitions for which a quorum of nodes is not available. A simple textual message is also returned in a body with content-type `text/plain`. -See `/v2/health` for an API that also returns JSON output. +See `/v2/GetClusterHealth` for an API that also returns JSON output. ### Other special endpoints diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index eac93b6e..39e05d51 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -13,9 +13,21 @@ use crate::admin::EndpointHandler; use crate::helpers::is_default; // This generates the following: +// // - An enum AdminApiRequest that contains a variant for all endpoints -// - An enum AdminApiResponse that contains a variant for all non-special endpoints +// +// - An enum AdminApiResponse that contains a variant for all non-special endpoints. +// This enum is serialized in api_server.rs, without the enum tag, +// which gives directly the JSON response corresponding to the API call. +// This enum does not implement Deserialize as its meaning can be ambiguous. +// +// - An enum TaggedAdminApiResponse that contains the same variants, but +// serializes as a tagged enum. This allows it to be transmitted through +// Garage RPC and deserialized correctly upon receival. +// Conversion from untagged to tagged can be done using the `.tagged()` method. +// // - AdminApiRequest::name() that returns the name of the endpoint +// // - impl EndpointHandler for AdminApiHandler, that uses the impl EndpointHandler // of each request type below for non-special endpoints admin_endpoints![ @@ -60,6 +72,9 @@ admin_endpoints![ // ********************************************** // Special endpoints +// +// These endpoints don't have associated *Response structs +// because they directly produce an http::Response // ********************************************** #[derive(Serialize, Deserialize)] @@ -153,11 +168,11 @@ pub struct GetClusterHealthResponse { pub struct ConnectClusterNodesRequest(pub Vec); #[derive(Serialize, Deserialize)] -pub struct ConnectClusterNodesResponse(pub Vec); +pub struct ConnectClusterNodesResponse(pub Vec); #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ConnectClusterNodeResponse { +pub struct ConnectNodeResponse { pub success: bool, pub error: Option, } @@ -331,7 +346,6 @@ pub struct UpdateKeyResponse(pub GetKeyInfoResponse); #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateKeyRequestBody { - // TODO: id (get parameter) goes here pub name: Option, pub allow: Option, pub deny: Option, diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 92da3245..b835322d 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -176,7 +176,7 @@ impl ApiHandler for AdminApiServer { impl ApiEndpoint for Endpoint { fn name(&self) -> Cow<'static, str> { match self { - Self::Old(endpoint_v1) => Cow::Owned(format!("v1:{}", endpoint_v1.name())), + Self::Old(endpoint_v1) => Cow::Borrowed(endpoint_v1.name()), Self::New(path) => Cow::Owned(path.clone()), } } diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 112cb542..0cfd744a 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -151,11 +151,11 @@ impl EndpointHandler for ConnectClusterNodesRequest { .await .into_iter() .map(|r| match r { - Ok(()) => ConnectClusterNodeResponse { + Ok(()) => ConnectNodeResponse { success: true, error: None, }, - Err(e) => ConnectClusterNodeResponse { + Err(e) => ConnectNodeResponse { success: false, error: Some(format!("{}", e)), }, diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index d68ba37f..7082577f 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -14,7 +14,7 @@ macro_rules! admin_endpoints { )* } - #[derive(Serialize, Deserialize)] + #[derive(Serialize)] #[serde(untagged)] pub enum AdminApiResponse { $( @@ -22,6 +22,13 @@ macro_rules! admin_endpoints { )* } + #[derive(Serialize, Deserialize)] + pub enum TaggedAdminApiResponse { + $( + $endpoint( [<$endpoint Response>] ), + )* + } + impl AdminApiRequest { pub fn name(&self) -> &'static str { match self { @@ -35,6 +42,16 @@ macro_rules! admin_endpoints { } } + impl AdminApiResponse { + fn tagged(self) -> TaggedAdminApiResponse { + match self { + $( + Self::$endpoint(res) => TaggedAdminApiResponse::$endpoint(res), + )* + } + } + } + #[async_trait] impl EndpointHandler for AdminApiRequest { type Response = AdminApiResponse; From 1c03941b192dc1c8418618166293c3fb5b9732a9 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 29 Jan 2025 12:46:20 +0100 Subject: [PATCH 029/192] admin api: fix panic on GetKeyInfo with no args --- src/api/admin/key.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 3e4201d9..d2f449ed 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -43,15 +43,19 @@ impl EndpointHandler for GetKeyInfoRequest { type Response = GetKeyInfoResponse; async fn handle(self, garage: &Arc) -> Result { - let key = if let Some(id) = self.id { - garage.key_helper().get_existing_key(&id).await? - } else if let Some(search) = self.search { - garage - .key_helper() - .get_existing_matching_key(&search) - .await? - } else { - unreachable!(); + let key = match (self.id, self.search) { + (Some(id), None) => garage.key_helper().get_existing_key(&id).await?, + (None, Some(search)) => { + garage + .key_helper() + .get_existing_matching_key(&search) + .await? + } + _ => { + return Err(Error::bad_request( + "Either id or search must be provided (but not both)", + )); + } }; Ok(key_info_results(garage, key, self.show_secret_key).await?) From 19454c1679352012f1953949d02880e34820039f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 29 Jan 2025 19:47:37 +0100 Subject: [PATCH 030/192] admin api: remove log message --- src/api/admin/api_server.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index b835322d..d66714db 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -134,8 +134,6 @@ impl ApiHandler for AdminApiServer { Endpoint::New(_) => AdminApiRequest::from_request(req).await?, }; - info!("Admin request: {}", request.name()); - let required_auth_hash = match request.authorization_type() { Authorization::None => None, From 145130481eac30793c6c08caa4d208ddddfc30e8 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 10:44:08 +0100 Subject: [PATCH 031/192] wip: proxy admin api requests through admin rpc, prepare new cli --- src/api/admin/api.rs | 135 ++++++++++++++++++++------------------- src/api/admin/error.rs | 2 +- src/api/admin/macros.rs | 26 ++++++-- src/garage/admin/mod.rs | 32 ++++++++++ src/garage/cli_v2/mod.rs | 63 ++++++++++++++++++ src/garage/main.rs | 14 ++-- 6 files changed, 194 insertions(+), 78 deletions(-) create mode 100644 src/garage/cli_v2/mod.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 39e05d51..52ecd501 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -1,3 +1,4 @@ +use std::convert::TryFrom; use std::net::SocketAddr; use std::sync::Arc; @@ -77,18 +78,18 @@ admin_endpoints![ // because they directly produce an http::Response // ********************************************** -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct OptionsRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CheckDomainRequest { pub domain: String, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetricsRequest; // ********************************************** @@ -97,10 +98,10 @@ pub struct MetricsRequest; // ---- GetClusterStatus ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetClusterStatusRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetClusterStatusResponse { pub node: String, @@ -112,7 +113,7 @@ pub struct GetClusterStatusResponse { pub nodes: Vec, } -#[derive(Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct NodeResp { pub id: String, @@ -128,7 +129,7 @@ pub struct NodeResp { pub metadata_partition: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeRoleResp { pub id: String, @@ -137,7 +138,7 @@ pub struct NodeRoleResp { pub tags: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreeSpaceResp { pub available: u64, @@ -146,7 +147,7 @@ pub struct FreeSpaceResp { // ---- GetClusterHealth ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetClusterHealthRequest; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -167,10 +168,10 @@ pub struct GetClusterHealthResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectClusterNodesRequest(pub Vec); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectClusterNodesResponse(pub Vec); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectNodeResponse { pub success: bool, @@ -179,10 +180,10 @@ pub struct ConnectNodeResponse { // ---- GetClusterLayout ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetClusterLayoutRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetClusterLayoutResponse { pub version: u64, @@ -190,7 +191,7 @@ pub struct GetClusterLayoutResponse { pub staged_role_changes: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeRoleChange { pub id: String, @@ -198,7 +199,7 @@ pub struct NodeRoleChange { pub action: NodeRoleChangeEnum, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum NodeRoleChangeEnum { #[serde(rename_all = "camelCase")] @@ -213,21 +214,21 @@ pub enum NodeRoleChangeEnum { // ---- UpdateClusterLayout ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateClusterLayoutRequest(pub Vec); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); // ---- ApplyClusterLayout ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutRequest { pub version: u64, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutResponse { pub message: Vec, @@ -236,10 +237,10 @@ pub struct ApplyClusterLayoutResponse { // ---- RevertClusterLayout ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RevertClusterLayoutRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); // ********************************************** @@ -248,13 +249,13 @@ pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); // ---- ListKeys ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListKeysRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListKeysResponse(pub Vec); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListKeysResponseItem { pub id: String, @@ -263,14 +264,14 @@ pub struct ListKeysResponseItem { // ---- GetKeyInfo ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetKeyInfoRequest { pub id: Option, pub search: Option, pub show_secret_key: bool, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetKeyInfoResponse { pub name: String, @@ -281,14 +282,14 @@ pub struct GetKeyInfoResponse { pub buckets: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeyPerm { #[serde(default)] pub create_bucket: bool, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeyInfoBucketResponse { pub id: String, @@ -297,7 +298,7 @@ pub struct KeyInfoBucketResponse { pub permissions: ApiBucketKeyPerm, } -#[derive(Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct ApiBucketKeyPerm { #[serde(default)] @@ -310,18 +311,18 @@ pub struct ApiBucketKeyPerm { // ---- CreateKey ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateKeyRequest { pub name: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateKeyResponse(pub GetKeyInfoResponse); // ---- ImportKey ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ImportKeyRequest { pub access_key_id: String, @@ -329,21 +330,21 @@ pub struct ImportKeyRequest { pub name: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportKeyResponse(pub GetKeyInfoResponse); // ---- UpdateKey ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateKeyRequest { pub id: String, pub body: UpdateKeyRequestBody, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateKeyResponse(pub GetKeyInfoResponse); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateKeyRequestBody { pub name: Option, @@ -353,12 +354,12 @@ pub struct UpdateKeyRequestBody { // ---- DeleteKey ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteKeyRequest { pub id: String, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteKeyResponse; // ********************************************** @@ -367,13 +368,13 @@ pub struct DeleteKeyResponse; // ---- ListBuckets ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListBucketsRequest; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListBucketsResponse(pub Vec); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListBucketsResponseItem { pub id: String, @@ -381,7 +382,7 @@ pub struct ListBucketsResponseItem { pub local_aliases: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BucketLocalAlias { pub access_key_id: String, @@ -390,13 +391,13 @@ pub struct BucketLocalAlias { // ---- GetBucketInfo ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetBucketInfoRequest { pub id: Option, pub global_alias: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoResponse { pub id: String, @@ -414,14 +415,14 @@ pub struct GetBucketInfoResponse { pub quotas: ApiBucketQuotas, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoWebsiteResponse { pub index_document: String, pub error_document: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoKey { pub access_key_id: String, @@ -430,7 +431,7 @@ pub struct GetBucketInfoKey { pub bucket_local_aliases: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiBucketQuotas { pub max_size: Option, @@ -439,17 +440,17 @@ pub struct ApiBucketQuotas { // ---- CreateBucket ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateBucketRequest { pub global_alias: Option, pub local_alias: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateBucketResponse(pub GetBucketInfoResponse); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateBucketLocalAlias { pub access_key_id: String, @@ -460,23 +461,23 @@ pub struct CreateBucketLocalAlias { // ---- UpdateBucket ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateBucketRequest { pub id: String, pub body: UpdateBucketRequestBody, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateBucketResponse(pub GetBucketInfoResponse); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateBucketRequestBody { pub website_access: Option, pub quotas: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateBucketWebsiteAccess { pub enabled: bool, @@ -486,12 +487,12 @@ pub struct UpdateBucketWebsiteAccess { // ---- DeleteBucket ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteBucketRequest { pub id: String, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteBucketResponse; // ********************************************** @@ -500,13 +501,13 @@ pub struct DeleteBucketResponse; // ---- AllowBucketKey ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AllowBucketKeyRequest(pub BucketKeyPermChangeRequest); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AllowBucketKeyResponse(pub GetBucketInfoResponse); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BucketKeyPermChangeRequest { pub bucket_id: String, @@ -516,10 +517,10 @@ pub struct BucketKeyPermChangeRequest { // ---- DenyBucketKey ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DenyBucketKeyRequest(pub BucketKeyPermChangeRequest); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // ********************************************** @@ -528,7 +529,7 @@ pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // ---- AddBucketAlias ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddBucketAliasRequest { pub bucket_id: String, @@ -536,10 +537,10 @@ pub struct AddBucketAliasRequest { pub alias: BucketAliasEnum, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AddBucketAliasResponse(pub GetBucketInfoResponse); -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum BucketAliasEnum { #[serde(rename_all = "camelCase")] @@ -553,7 +554,7 @@ pub enum BucketAliasEnum { // ---- RemoveBucketAlias ---- -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoveBucketAliasRequest { pub bucket_id: String, @@ -561,5 +562,5 @@ pub struct RemoveBucketAliasRequest { pub alias: BucketAliasEnum, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index 40d686e3..205fc314 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -56,7 +56,7 @@ impl From for Error { impl CommonErrorDerivative for Error {} impl Error { - fn code(&self) -> &'static str { + pub fn code(&self) -> &'static str { match self { Error::Common(c) => c.aws_code(), Error::NoSuchAccessKey(_) => "NoSuchAccessKey", diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index 7082577f..9521616e 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -4,7 +4,7 @@ macro_rules! admin_endpoints { $($endpoint:ident,)* ] => { paste! { - #[derive(Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AdminApiRequest { $( $special_endpoint( [<$special_endpoint Request>] ), @@ -14,7 +14,7 @@ macro_rules! admin_endpoints { )* } - #[derive(Serialize)] + #[derive(Debug, Clone, Serialize)] #[serde(untagged)] pub enum AdminApiResponse { $( @@ -22,7 +22,7 @@ macro_rules! admin_endpoints { )* } - #[derive(Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TaggedAdminApiResponse { $( $endpoint( [<$endpoint Response>] ), @@ -43,7 +43,7 @@ macro_rules! admin_endpoints { } impl AdminApiResponse { - fn tagged(self) -> TaggedAdminApiResponse { + pub fn tagged(self) -> TaggedAdminApiResponse { match self { $( Self::$endpoint(res) => TaggedAdminApiResponse::$endpoint(res), @@ -52,6 +52,24 @@ macro_rules! admin_endpoints { } } + $( + impl From< [< $endpoint Request >] > for AdminApiRequest { + fn from(req: [< $endpoint Request >]) -> AdminApiRequest { + AdminApiRequest::$endpoint(req) + } + } + + impl TryFrom for [< $endpoint Response >] { + type Error = TaggedAdminApiResponse; + fn try_from(resp: TaggedAdminApiResponse) -> Result< [< $endpoint Response >], TaggedAdminApiResponse> { + match resp { + TaggedAdminApiResponse::$endpoint(v) => Ok(v), + x => Err(x), + } + } + } + )* + #[async_trait] impl EndpointHandler for AdminApiRequest { type Response = AdminApiResponse; diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index e2468143..4c460b8d 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -30,6 +30,10 @@ use garage_model::key_table::*; use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::Version; +use garage_api::admin::api::{AdminApiRequest, TaggedAdminApiResponse}; +use garage_api::admin::EndpointHandler as AdminApiEndpoint; +use garage_api::generic_server::ApiError; + use crate::cli::*; use crate::repair::online::launch_online_repair; @@ -70,6 +74,15 @@ pub enum AdminRpc { versions: Vec>, uploads: Vec, }, + + // Proxying HTTP Admin API endpoints + ApiRequest(AdminApiRequest), + ApiOkResponse(TaggedAdminApiResponse), + ApiErrorResponse { + http_code: u16, + error_code: String, + message: String, + }, } impl Rpc for AdminRpc { @@ -503,6 +516,24 @@ impl AdminRpcHandler { } } } + + // ================== PROXYING ADMIN API REQUESTS =================== + + async fn handle_api_request( + self: &Arc, + req: &AdminApiRequest, + ) -> Result { + let req = req.clone(); + let res = req.handle(&self.garage).await; + match res { + Ok(res) => Ok(AdminRpc::ApiOkResponse(res.tagged())), + Err(e) => Ok(AdminRpc::ApiErrorResponse { + http_code: e.http_status_code().as_u16(), + error_code: e.code().to_string(), + message: e.to_string(), + }), + } + } } #[async_trait] @@ -520,6 +551,7 @@ impl EndpointHandler for AdminRpcHandler { AdminRpc::Worker(wo) => self.handle_worker_cmd(wo).await, AdminRpc::BlockOperation(bo) => self.handle_block_cmd(bo).await, AdminRpc::MetaOperation(mo) => self.handle_meta_cmd(mo).await, + AdminRpc::ApiRequest(r) => self.handle_api_request(r).await, m => Err(GarageError::unexpected_rpc_message(m).into()), } } diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs new file mode 100644 index 00000000..6cf068c6 --- /dev/null +++ b/src/garage/cli_v2/mod.rs @@ -0,0 +1,63 @@ +use std::collections::{HashMap, HashSet}; +use std::convert::TryFrom; +use std::sync::Arc; +use std::time::Duration; + +use format_table::format_table; +use garage_util::error::*; + +use garage_rpc::layout::*; +use garage_rpc::system::*; +use garage_rpc::*; + +use garage_api::admin::api::*; +use garage_api::admin::EndpointHandler as AdminApiEndpoint; + +use crate::admin::*; +use crate::cli::*; + +pub struct Cli { + pub system_rpc_endpoint: Arc>, + pub admin_rpc_endpoint: Arc>, + pub rpc_host: NodeID, +} + +impl Cli { + pub async fn handle(&self, cmd: Command) -> Result<(), Error> { + println!("{:?}", self.api_request(GetClusterStatusRequest).await?); + Ok(()) + /* + match cmd { + _ => todo!(), + } + */ + } + + pub async fn api_request(&self, req: T) -> Result<::Response, Error> + where + T: AdminApiEndpoint, + AdminApiRequest: From, + ::Response: TryFrom, + { + let req = AdminApiRequest::from(req); + let req_name = req.name(); + match self + .admin_rpc_endpoint + .call(&self.rpc_host, AdminRpc::ApiRequest(req), PRIO_NORMAL) + .await? + .ok_or_message("xoxo")? + { + AdminRpc::ApiOkResponse(resp) => ::Response::try_from(resp) + .map_err(|_| Error::Message(format!("{} returned unexpected response", req_name))), + AdminRpc::ApiErrorResponse { + http_code, + error_code, + message, + } => Err(Error::Message(format!( + "{} returned {} ({}): {}", + req_name, error_code, http_code, message + ))), + m => Err(Error::unexpected_rpc_message(m)), + } + } +} diff --git a/src/garage/main.rs b/src/garage/main.rs index ac95e854..8b5af5ea 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -6,6 +6,7 @@ extern crate tracing; mod admin; mod cli; +mod cli_v2; mod repair; mod secrets; mod server; @@ -284,10 +285,11 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { let system_rpc_endpoint = netapp.endpoint::(SYSTEM_RPC_PATH.into()); let admin_rpc_endpoint = netapp.endpoint::(ADMIN_RPC_PATH.into()); - match cli_command_dispatch(opt.cmd, &system_rpc_endpoint, &admin_rpc_endpoint, id).await { - Err(HelperError::Internal(i)) => Err(Error::Message(format!("Internal error: {}", i))), - Err(HelperError::BadRequest(b)) => Err(Error::Message(b)), - Err(e) => Err(Error::Message(format!("{}", e))), - Ok(x) => Ok(x), - } + let cli = cli_v2::Cli { + system_rpc_endpoint, + admin_rpc_endpoint, + rpc_host: id, + }; + + cli.handle(opt.cmd).await } From 69ddaafc6061d06d277fe772dfaa7fe64ecafcc1 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 12:07:12 +0100 Subject: [PATCH 032/192] wip: migrate garage status and garage layout assign --- src/garage/cli/cmd.rs | 203 ----------------------------------- src/garage/cli/layout.rs | 141 ------------------------ src/garage/cli/mod.rs | 1 - src/garage/cli_v2/cluster.rs | 188 ++++++++++++++++++++++++++++++++ src/garage/cli_v2/layout.rs | 119 ++++++++++++++++++++ src/garage/cli_v2/mod.rs | 72 +++++++++++-- src/garage/cli_v2/util.rs | 115 ++++++++++++++++++++ src/garage/main.rs | 2 - 8 files changed, 486 insertions(+), 355 deletions(-) create mode 100644 src/garage/cli_v2/cluster.rs create mode 100644 src/garage/cli_v2/layout.rs create mode 100644 src/garage/cli_v2/util.rs diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index 44d3d96c..2b5f93d4 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -1,10 +1,5 @@ -use std::collections::{HashMap, HashSet}; -use std::time::Duration; - -use format_table::format_table; use garage_util::error::*; -use garage_rpc::layout::*; use garage_rpc::system::*; use garage_rpc::*; @@ -13,204 +8,6 @@ use garage_model::helper::error::Error as HelperError; use crate::admin::*; use crate::cli::*; -pub async fn cli_command_dispatch( - cmd: Command, - system_rpc_endpoint: &Endpoint, - admin_rpc_endpoint: &Endpoint, - rpc_host: NodeID, -) -> Result<(), HelperError> { - match cmd { - Command::Status => Ok(cmd_status(system_rpc_endpoint, rpc_host).await?), - Command::Node(NodeOperation::Connect(connect_opt)) => { - Ok(cmd_connect(system_rpc_endpoint, rpc_host, connect_opt).await?) - } - Command::Layout(layout_opt) => { - Ok(cli_layout_command_dispatch(layout_opt, system_rpc_endpoint, rpc_host).await?) - } - Command::Bucket(bo) => { - cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::BucketOperation(bo)).await - } - Command::Key(ko) => { - cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::KeyOperation(ko)).await - } - Command::Repair(ro) => { - cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::LaunchRepair(ro)).await - } - Command::Stats(so) => cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::Stats(so)).await, - Command::Worker(wo) => cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::Worker(wo)).await, - Command::Block(bo) => { - cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::BlockOperation(bo)).await - } - Command::Meta(mo) => { - cmd_admin(admin_rpc_endpoint, rpc_host, AdminRpc::MetaOperation(mo)).await - } - _ => unreachable!(), - } -} - -pub async fn cmd_status(rpc_cli: &Endpoint, rpc_host: NodeID) -> Result<(), Error> { - let status = fetch_status(rpc_cli, rpc_host).await?; - let layout = fetch_layout(rpc_cli, rpc_host).await?; - - println!("==== HEALTHY NODES ===="); - let mut healthy_nodes = - vec!["ID\tHostname\tAddress\tTags\tZone\tCapacity\tDataAvail".to_string()]; - for adv in status.iter().filter(|adv| adv.is_up) { - let host = adv.status.hostname.as_deref().unwrap_or("?"); - let addr = match adv.addr { - Some(addr) => addr.to_string(), - None => "N/A".to_string(), - }; - if let Some(NodeRoleV(Some(cfg))) = layout.current().roles.get(&adv.id) { - let data_avail = match &adv.status.data_disk_avail { - _ if cfg.capacity.is_none() => "N/A".into(), - Some((avail, total)) => { - let pct = (*avail as f64) / (*total as f64) * 100.; - let avail = bytesize::ByteSize::b(*avail); - format!("{} ({:.1}%)", avail, pct) - } - None => "?".into(), - }; - healthy_nodes.push(format!( - "{id:?}\t{host}\t{addr}\t[{tags}]\t{zone}\t{capacity}\t{data_avail}", - id = adv.id, - host = host, - addr = addr, - tags = cfg.tags.join(","), - zone = cfg.zone, - capacity = cfg.capacity_string(), - data_avail = data_avail, - )); - } else { - let prev_role = layout - .versions - .iter() - .rev() - .find_map(|x| match x.roles.get(&adv.id) { - Some(NodeRoleV(Some(cfg))) => Some(cfg), - _ => None, - }); - if let Some(cfg) = prev_role { - healthy_nodes.push(format!( - "{id:?}\t{host}\t{addr}\t[{tags}]\t{zone}\tdraining metadata...", - id = adv.id, - host = host, - addr = addr, - tags = cfg.tags.join(","), - zone = cfg.zone, - )); - } else { - let new_role = match layout.staging.get().roles.get(&adv.id) { - Some(NodeRoleV(Some(_))) => "pending...", - _ => "NO ROLE ASSIGNED", - }; - healthy_nodes.push(format!( - "{id:?}\t{h}\t{addr}\t\t\t{new_role}", - id = adv.id, - h = host, - addr = addr, - new_role = new_role, - )); - } - } - } - format_table(healthy_nodes); - - // Determine which nodes are unhealthy and print that to stdout - let status_map = status - .iter() - .map(|adv| (adv.id, adv)) - .collect::>(); - - let tf = timeago::Formatter::new(); - let mut drain_msg = false; - let mut failed_nodes = vec!["ID\tHostname\tTags\tZone\tCapacity\tLast seen".to_string()]; - let mut listed = HashSet::new(); - for ver in layout.versions.iter().rev() { - for (node, _, role) in ver.roles.items().iter() { - let cfg = match role { - NodeRoleV(Some(role)) if role.capacity.is_some() => role, - _ => continue, - }; - - if listed.contains(node) { - continue; - } - listed.insert(*node); - - let adv = status_map.get(node); - if adv.map(|x| x.is_up).unwrap_or(false) { - continue; - } - - // Node is in a layout version, is not a gateway node, and is not up: - // it is in a failed state, add proper line to the output - let (host, last_seen) = match adv { - Some(adv) => ( - adv.status.hostname.as_deref().unwrap_or("?"), - adv.last_seen_secs_ago - .map(|s| tf.convert(Duration::from_secs(s))) - .unwrap_or_else(|| "never seen".into()), - ), - None => ("??", "never seen".into()), - }; - let capacity = if ver.version == layout.current().version { - cfg.capacity_string() - } else { - drain_msg = true; - "draining metadata...".to_string() - }; - failed_nodes.push(format!( - "{id:?}\t{host}\t[{tags}]\t{zone}\t{capacity}\t{last_seen}", - id = node, - host = host, - tags = cfg.tags.join(","), - zone = cfg.zone, - capacity = capacity, - last_seen = last_seen, - )); - } - } - - if failed_nodes.len() > 1 { - println!("\n==== FAILED NODES ===="); - format_table(failed_nodes); - if drain_msg { - println!(); - println!("Your cluster is expecting to drain data from nodes that are currently unavailable."); - println!("If these nodes are definitely dead, please review the layout history with"); - println!( - "`garage layout history` and use `garage layout skip-dead-nodes` to force progress." - ); - } - } - - if print_staging_role_changes(&layout) { - println!(); - println!("Please use `garage layout show` to check the proposed new layout and apply it."); - println!(); - } - - Ok(()) -} - -pub async fn cmd_connect( - rpc_cli: &Endpoint, - rpc_host: NodeID, - args: ConnectNodeOpt, -) -> Result<(), Error> { - match rpc_cli - .call(&rpc_host, SystemRpc::Connect(args.node), PRIO_NORMAL) - .await?? - { - SystemRpc::Ok => { - println!("Success."); - Ok(()) - } - m => Err(Error::unexpected_rpc_message(m)), - } -} - pub async fn cmd_admin( rpc_cli: &Endpoint, rpc_host: NodeID, diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index f053eef4..d0b62fc7 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -10,147 +10,6 @@ use garage_rpc::*; use crate::cli::*; -pub async fn cli_layout_command_dispatch( - cmd: LayoutOperation, - system_rpc_endpoint: &Endpoint, - rpc_host: NodeID, -) -> Result<(), Error> { - match cmd { - LayoutOperation::Assign(assign_opt) => { - cmd_assign_role(system_rpc_endpoint, rpc_host, assign_opt).await - } - LayoutOperation::Remove(remove_opt) => { - cmd_remove_role(system_rpc_endpoint, rpc_host, remove_opt).await - } - LayoutOperation::Show => cmd_show_layout(system_rpc_endpoint, rpc_host).await, - LayoutOperation::Apply(apply_opt) => { - cmd_apply_layout(system_rpc_endpoint, rpc_host, apply_opt).await - } - LayoutOperation::Revert(revert_opt) => { - cmd_revert_layout(system_rpc_endpoint, rpc_host, revert_opt).await - } - LayoutOperation::Config(config_opt) => { - cmd_config_layout(system_rpc_endpoint, rpc_host, config_opt).await - } - LayoutOperation::History => cmd_layout_history(system_rpc_endpoint, rpc_host).await, - LayoutOperation::SkipDeadNodes(assume_sync_opt) => { - cmd_layout_skip_dead_nodes(system_rpc_endpoint, rpc_host, assume_sync_opt).await - } - } -} - -pub async fn cmd_assign_role( - rpc_cli: &Endpoint, - rpc_host: NodeID, - args: AssignRoleOpt, -) -> Result<(), Error> { - let status = match rpc_cli - .call(&rpc_host, SystemRpc::GetKnownNodes, PRIO_NORMAL) - .await?? - { - SystemRpc::ReturnKnownNodes(nodes) => nodes, - resp => return Err(Error::Message(format!("Invalid RPC response: {:?}", resp))), - }; - - let mut layout = fetch_layout(rpc_cli, rpc_host).await?; - let all_nodes = layout.get_all_nodes(); - - let added_nodes = args - .node_ids - .iter() - .map(|node_id| { - find_matching_node( - status - .iter() - .map(|adv| adv.id) - .chain(all_nodes.iter().cloned()), - node_id, - ) - }) - .collect::, _>>()?; - - let mut roles = layout.current().roles.clone(); - roles.merge(&layout.staging.get().roles); - - for replaced in args.replace.iter() { - let replaced_node = find_matching_node(all_nodes.iter().cloned(), replaced)?; - match roles.get(&replaced_node) { - Some(NodeRoleV(Some(_))) => { - layout - .staging - .get_mut() - .roles - .merge(&roles.update_mutator(replaced_node, NodeRoleV(None))); - } - _ => { - return Err(Error::Message(format!( - "Cannot replace node {:?} as it is not currently in planned layout", - replaced_node - ))); - } - } - } - - if args.capacity.is_some() && args.gateway { - return Err(Error::Message( - "-c and -g are mutually exclusive, please configure node either with c>0 to act as a storage node or with -g to act as a gateway node".into())); - } - if args.capacity == Some(ByteSize::b(0)) { - return Err(Error::Message("Invalid capacity value: 0".into())); - } - - for added_node in added_nodes { - let new_entry = match roles.get(&added_node) { - Some(NodeRoleV(Some(old))) => { - let capacity = match args.capacity { - Some(c) => Some(c.as_u64()), - None if args.gateway => None, - None => old.capacity, - }; - let tags = if args.tags.is_empty() { - old.tags.clone() - } else { - args.tags.clone() - }; - NodeRole { - zone: args.zone.clone().unwrap_or_else(|| old.zone.to_string()), - capacity, - tags, - } - } - _ => { - let capacity = match args.capacity { - Some(c) => Some(c.as_u64()), - None if args.gateway => None, - None => return Err(Error::Message( - "Please specify a capacity with the -c flag, or set node explicitly as gateway with -g".into())), - }; - NodeRole { - zone: args - .zone - .clone() - .ok_or("Please specify a zone with the -z flag")?, - capacity, - tags: args.tags.clone(), - } - } - }; - - layout - .staging - .get_mut() - .roles - .merge(&roles.update_mutator(added_node, NodeRoleV(Some(new_entry)))); - } - - send_layout(rpc_cli, rpc_host, layout).await?; - - println!("Role changes are staged but not yet committed."); - println!("Use `garage layout show` to view staged role changes,"); - println!("and `garage layout apply` to enact staged changes."); - Ok(()) -} - pub async fn cmd_remove_role( rpc_cli: &Endpoint, rpc_host: NodeID, diff --git a/src/garage/cli/mod.rs b/src/garage/cli/mod.rs index e131f62c..30f566e2 100644 --- a/src/garage/cli/mod.rs +++ b/src/garage/cli/mod.rs @@ -8,6 +8,5 @@ pub(crate) mod convert_db; pub(crate) use cmd::*; pub(crate) use init::*; -pub(crate) use layout::*; pub(crate) use structs::*; pub(crate) use util::*; diff --git a/src/garage/cli_v2/cluster.rs b/src/garage/cli_v2/cluster.rs new file mode 100644 index 00000000..0b5b9559 --- /dev/null +++ b/src/garage/cli_v2/cluster.rs @@ -0,0 +1,188 @@ +use format_table::format_table; + +use garage_util::error::*; + +use garage_api::admin::api::*; + +use crate::cli::structs::*; +use crate::cli_v2::util::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn cmd_status(&self) -> Result<(), Error> { + let status = self.api_request(GetClusterStatusRequest).await?; + let layout = self.api_request(GetClusterLayoutRequest).await?; + // TODO: layout history + + println!("==== HEALTHY NODES ===="); + let mut healthy_nodes = + vec!["ID\tHostname\tAddress\tTags\tZone\tCapacity\tDataAvail".to_string()]; + for adv in status.nodes.iter().filter(|adv| adv.is_up) { + let host = adv.hostname.as_deref().unwrap_or("?"); + let addr = match adv.addr { + Some(addr) => addr.to_string(), + None => "N/A".to_string(), + }; + if let Some(cfg) = &adv.role { + let data_avail = match &adv.data_partition { + _ if cfg.capacity.is_none() => "N/A".into(), + Some(FreeSpaceResp { available, total }) => { + let pct = (*available as f64) / (*total as f64) * 100.; + let avail_str = bytesize::ByteSize::b(*available); + format!("{} ({:.1}%)", avail_str, pct) + } + None => "?".into(), + }; + healthy_nodes.push(format!( + "{id:.16}\t{host}\t{addr}\t[{tags}]\t{zone}\t{capacity}\t{data_avail}", + id = adv.id, + host = host, + addr = addr, + tags = cfg.tags.join(","), + zone = cfg.zone, + capacity = capacity_string(cfg.capacity), + data_avail = data_avail, + )); + } else { + /* + let prev_role = layout + .versions + .iter() + .rev() + .find_map(|x| match x.roles.get(&adv.id) { + Some(NodeRoleV(Some(cfg))) => Some(cfg), + _ => None, + }); + */ + let prev_role = Option::::None; //TODO + if let Some(cfg) = prev_role { + healthy_nodes.push(format!( + "{id:.16}\t{host}\t{addr}\t[{tags}]\t{zone}\tdraining metadata...", + id = adv.id, + host = host, + addr = addr, + tags = cfg.tags.join(","), + zone = cfg.zone, + )); + } else { + let new_role = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) + { + Some(_) => "pending...", + _ => "NO ROLE ASSIGNED", + }; + healthy_nodes.push(format!( + "{id:?}\t{h}\t{addr}\t\t\t{new_role}", + id = adv.id, + h = host, + addr = addr, + new_role = new_role, + )); + } + } + } + format_table(healthy_nodes); + + // Determine which nodes are unhealthy and print that to stdout + // TODO: do we need this, or can it be done in the GetClusterStatus handler? + let status_map = status + .nodes + .iter() + .map(|adv| (&adv.id, adv)) + .collect::>(); + + let tf = timeago::Formatter::new(); + let mut drain_msg = false; + let mut failed_nodes = vec!["ID\tHostname\tTags\tZone\tCapacity\tLast seen".to_string()]; + let mut listed = HashSet::new(); + //for ver in layout.versions.iter().rev() { + for ver in [&layout].iter() { + for cfg in ver.roles.iter() { + let node = &cfg.id; + if listed.contains(node.as_str()) { + continue; + } + listed.insert(node.as_str()); + + let adv = status_map.get(node); + if adv.map(|x| x.is_up).unwrap_or(false) { + continue; + } + + // Node is in a layout version, is not a gateway node, and is not up: + // it is in a failed state, add proper line to the output + let (host, last_seen) = match adv { + Some(adv) => ( + adv.hostname.as_deref().unwrap_or("?"), + adv.last_seen_secs_ago + .map(|s| tf.convert(Duration::from_secs(s))) + .unwrap_or_else(|| "never seen".into()), + ), + None => ("??", "never seen".into()), + }; + /* + let capacity = if ver.version == layout.current().version { + cfg.capacity_string() + } else { + drain_msg = true; + "draining metadata...".to_string() + }; + */ + let capacity = capacity_string(cfg.capacity); + + failed_nodes.push(format!( + "{id:?}\t{host}\t[{tags}]\t{zone}\t{capacity}\t{last_seen}", + id = node, + host = host, + tags = cfg.tags.join(","), + zone = cfg.zone, + capacity = capacity, + last_seen = last_seen, + )); + } + } + + if failed_nodes.len() > 1 { + println!("\n==== FAILED NODES ===="); + format_table(failed_nodes); + if drain_msg { + println!(); + println!("Your cluster is expecting to drain data from nodes that are currently unavailable."); + println!( + "If these nodes are definitely dead, please review the layout history with" + ); + println!( + "`garage layout history` and use `garage layout skip-dead-nodes` to force progress." + ); + } + } + + if print_staging_role_changes(&layout) { + println!(); + println!( + "Please use `garage layout show` to check the proposed new layout and apply it." + ); + println!(); + } + + Ok(()) + } + + pub async fn cmd_connect(&self, opt: ConnectNodeOpt) -> Result<(), Error> { + let res = self + .api_request(ConnectClusterNodesRequest(vec![opt.node])) + .await?; + if res.0.len() != 1 { + return Err(Error::Message(format!("unexpected response: {:?}", res))); + } + let res = res.0.into_iter().next().unwrap(); + if res.success { + println!("Success."); + Ok(()) + } else { + Err(Error::Message(format!( + "Failure: {}", + res.error.unwrap_or_default() + ))) + } + } +} diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs new file mode 100644 index 00000000..ccd1886f --- /dev/null +++ b/src/garage/cli_v2/layout.rs @@ -0,0 +1,119 @@ +use bytesize::ByteSize; +use format_table::format_table; + +use garage_util::error::*; + +use garage_api::admin::api::*; + +use crate::cli::layout as cli_v1; +use crate::cli::structs::*; +use crate::cli_v2::util::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn layout_command_dispatch(&self, cmd: LayoutOperation) -> Result<(), Error> { + match cmd { + LayoutOperation::Assign(assign_opt) => self.cmd_assign_role(assign_opt).await, + + // TODO + LayoutOperation::Remove(remove_opt) => { + cli_v1::cmd_remove_role(&self.system_rpc_endpoint, self.rpc_host, remove_opt).await + } + LayoutOperation::Show => { + cli_v1::cmd_show_layout(&self.system_rpc_endpoint, self.rpc_host).await + } + LayoutOperation::Apply(apply_opt) => { + cli_v1::cmd_apply_layout(&self.system_rpc_endpoint, self.rpc_host, apply_opt).await + } + LayoutOperation::Revert(revert_opt) => { + cli_v1::cmd_revert_layout(&self.system_rpc_endpoint, self.rpc_host, revert_opt) + .await + } + LayoutOperation::Config(config_opt) => { + cli_v1::cmd_config_layout(&self.system_rpc_endpoint, self.rpc_host, config_opt) + .await + } + LayoutOperation::History => { + cli_v1::cmd_layout_history(&self.system_rpc_endpoint, self.rpc_host).await + } + LayoutOperation::SkipDeadNodes(assume_sync_opt) => { + cli_v1::cmd_layout_skip_dead_nodes( + &self.system_rpc_endpoint, + self.rpc_host, + assume_sync_opt, + ) + .await + } + } + } + + pub async fn cmd_assign_role(&self, opt: AssignRoleOpt) -> Result<(), Error> { + let status = self.api_request(GetClusterStatusRequest).await?; + let layout = self.api_request(GetClusterLayoutRequest).await?; + + let all_node_ids_iter = status + .nodes + .iter() + .map(|x| x.id.as_str()) + .chain(layout.roles.iter().map(|x| x.id.as_str())); + + let mut actions = vec![]; + + for node in opt.replace.iter() { + let id = find_matching_node(all_node_ids_iter.clone(), &node)?; + + actions.push(NodeRoleChange { + id, + action: NodeRoleChangeEnum::Remove { remove: true }, + }); + } + + for node in opt.node_ids.iter() { + let id = find_matching_node(all_node_ids_iter.clone(), &node)?; + + let current = get_staged_or_current_role(&id, &layout); + + let zone = opt + .zone + .clone() + .or_else(|| current.as_ref().map(|c| c.zone.clone())) + .ok_or_message("Please specify a zone with the -z flag")?; + + let capacity = if opt.gateway { + if opt.capacity.is_some() { + return Err(Error::Message("Please specify only -c or -g".into())); + } + None + } else if let Some(cap) = opt.capacity { + Some(cap.as_u64()) + } else { + current.as_ref().ok_or_message("Please specify a capacity with the -c flag, or set node explicitly as gateway with -g")?.capacity + }; + + let tags = if !opt.tags.is_empty() { + opt.tags.clone() + } else if let Some(cur) = current.as_ref() { + cur.tags.clone() + } else { + vec![] + }; + + actions.push(NodeRoleChange { + id, + action: NodeRoleChangeEnum::Update { + zone, + capacity, + tags, + }, + }); + } + + self.api_request(UpdateClusterLayoutRequest(actions)) + .await?; + + println!("Role changes are staged but not yet committed."); + println!("Use `garage layout show` to view staged role changes,"); + println!("and `garage layout apply` to enact staged changes."); + Ok(()) + } +} diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 6cf068c6..2fe45e29 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -1,12 +1,15 @@ +pub mod util; + +pub mod cluster; +pub mod layout; + use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; use std::sync::Arc; use std::time::Duration; -use format_table::format_table; use garage_util::error::*; -use garage_rpc::layout::*; use garage_rpc::system::*; use garage_rpc::*; @@ -14,7 +17,9 @@ use garage_api::admin::api::*; use garage_api::admin::EndpointHandler as AdminApiEndpoint; use crate::admin::*; -use crate::cli::*; +use crate::cli as cli_v1; +use crate::cli::structs::*; +use crate::cli::Command; pub struct Cli { pub system_rpc_endpoint: Arc>, @@ -24,13 +29,64 @@ pub struct Cli { impl Cli { pub async fn handle(&self, cmd: Command) -> Result<(), Error> { - println!("{:?}", self.api_request(GetClusterStatusRequest).await?); - Ok(()) - /* match cmd { - _ => todo!(), + Command::Status => self.cmd_status().await, + Command::Node(NodeOperation::Connect(connect_opt)) => { + self.cmd_connect(connect_opt).await + } + Command::Layout(layout_opt) => self.layout_command_dispatch(layout_opt).await, + + // TODO + Command::Bucket(bo) => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::BucketOperation(bo), + ) + .await + .ok_or_message("xoxo"), + Command::Key(ko) => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::KeyOperation(ko), + ) + .await + .ok_or_message("xoxo"), + Command::Repair(ro) => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::LaunchRepair(ro), + ) + .await + .ok_or_message("xoxo"), + Command::Stats(so) => { + cli_v1::cmd_admin(&self.admin_rpc_endpoint, self.rpc_host, AdminRpc::Stats(so)) + .await + .ok_or_message("xoxo") + } + Command::Worker(wo) => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::Worker(wo), + ) + .await + .ok_or_message("xoxo"), + Command::Block(bo) => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::BlockOperation(bo), + ) + .await + .ok_or_message("xoxo"), + Command::Meta(mo) => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::MetaOperation(mo), + ) + .await + .ok_or_message("xoxo"), + + _ => unreachable!(), } - */ } pub async fn api_request(&self, req: T) -> Result<::Response, Error> diff --git a/src/garage/cli_v2/util.rs b/src/garage/cli_v2/util.rs new file mode 100644 index 00000000..78399b0d --- /dev/null +++ b/src/garage/cli_v2/util.rs @@ -0,0 +1,115 @@ +use bytesize::ByteSize; +use format_table::format_table; + +use garage_util::error::Error; + +use garage_api::admin::api::*; + +pub fn capacity_string(v: Option) -> String { + match v { + Some(c) => ByteSize::b(c).to_string_as(false), + None => "gateway".to_string(), + } +} + +pub fn get_staged_or_current_role( + id: &str, + layout: &GetClusterLayoutResponse, +) -> Option { + for node in layout.staged_role_changes.iter() { + if node.id == id { + return match &node.action { + NodeRoleChangeEnum::Remove { .. } => None, + NodeRoleChangeEnum::Update { + zone, + capacity, + tags, + } => Some(NodeRoleResp { + id: id.to_string(), + zone: zone.to_string(), + capacity: *capacity, + tags: tags.clone(), + }), + }; + } + } + + for node in layout.roles.iter() { + if node.id == id { + return Some(node.clone()); + } + } + + None +} + +pub fn find_matching_node<'a>( + cand: impl std::iter::Iterator, + pattern: &'a str, +) -> Result { + let mut candidates = vec![]; + for c in cand { + if c.starts_with(pattern) && !candidates.contains(&c) { + candidates.push(c); + } + } + if candidates.len() != 1 { + Err(Error::Message(format!( + "{} nodes match '{}'", + candidates.len(), + pattern, + ))) + } else { + Ok(candidates[0].to_string()) + } +} + +pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { + let has_role_changes = !layout.staged_role_changes.is_empty(); + + // TODO!! Layout parameters + let has_layout_changes = false; + + if has_role_changes || has_layout_changes { + println!(); + println!("==== STAGED ROLE CHANGES ===="); + if has_role_changes { + let mut table = vec!["ID\tTags\tZone\tCapacity".to_string()]; + for change in layout.staged_role_changes.iter() { + match &change.action { + NodeRoleChangeEnum::Update { + tags, + zone, + capacity, + } => { + let tags = tags.join(","); + table.push(format!( + "{:.16}\t{}\t{}\t{}", + change.id, + tags, + zone, + capacity_string(*capacity), + )); + } + NodeRoleChangeEnum::Remove { .. } => { + table.push(format!("{:.16}\tREMOVED", change.id)); + } + } + } + format_table(table); + println!(); + } + //TODO + /* + if has_layout_changes { + println!( + "Zone redundancy: {}", + staging.parameters.get().zone_redundancy + ); + } + */ + true + } else { + false + } +} diff --git a/src/garage/main.rs b/src/garage/main.rs index 8b5af5ea..08c7cee7 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -35,8 +35,6 @@ use garage_util::error::*; use garage_rpc::system::*; use garage_rpc::*; -use garage_model::helper::error::Error as HelperError; - use admin::*; use cli::*; use secrets::Secrets; From 819f4f00509a57097d0ee8291e1556829e982e14 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 12:19:23 +0100 Subject: [PATCH 033/192] cli: migrate layout remove, apply, revert --- src/garage/cli/layout.rs | 69 --------------------------------- src/garage/cli/util.rs | 21 ---------- src/garage/cli_v2/layout.rs | 77 +++++++++++++++++++++++++++++++------ 3 files changed, 65 insertions(+), 102 deletions(-) diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index d0b62fc7..bb81d144 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -1,7 +1,6 @@ use bytesize::ByteSize; use format_table::format_table; -use garage_util::crdt::Crdt; use garage_util::error::*; use garage_rpc::layout::*; @@ -10,33 +9,6 @@ use garage_rpc::*; use crate::cli::*; -pub async fn cmd_remove_role( - rpc_cli: &Endpoint, - rpc_host: NodeID, - args: RemoveRoleOpt, -) -> Result<(), Error> { - let mut layout = fetch_layout(rpc_cli, rpc_host).await?; - - let mut roles = layout.current().roles.clone(); - roles.merge(&layout.staging.get().roles); - - let deleted_node = - find_matching_node(roles.items().iter().map(|(id, _, _)| *id), &args.node_id)?; - - layout - .staging - .get_mut() - .roles - .merge(&roles.update_mutator(deleted_node, NodeRoleV(None))); - - send_layout(rpc_cli, rpc_host, layout).await?; - - println!("Role removal is staged but not yet committed."); - println!("Use `garage layout show` to view staged role changes,"); - println!("and `garage layout apply` to enact staged changes."); - Ok(()) -} - pub async fn cmd_show_layout( rpc_cli: &Endpoint, rpc_host: NodeID, @@ -85,47 +57,6 @@ pub async fn cmd_show_layout( Ok(()) } -pub async fn cmd_apply_layout( - rpc_cli: &Endpoint, - rpc_host: NodeID, - apply_opt: ApplyLayoutOpt, -) -> Result<(), Error> { - let layout = fetch_layout(rpc_cli, rpc_host).await?; - - let (layout, msg) = layout.apply_staged_changes(apply_opt.version)?; - for line in msg.iter() { - println!("{}", line); - } - - send_layout(rpc_cli, rpc_host, layout).await?; - - println!("New cluster layout with updated role assignment has been applied in cluster."); - println!("Data will now be moved around between nodes accordingly."); - - Ok(()) -} - -pub async fn cmd_revert_layout( - rpc_cli: &Endpoint, - rpc_host: NodeID, - revert_opt: RevertLayoutOpt, -) -> Result<(), Error> { - if !revert_opt.yes { - return Err(Error::Message( - "Please add the --yes flag to run the layout revert operation".into(), - )); - } - - let layout = fetch_layout(rpc_cli, rpc_host).await?; - - let layout = layout.revert_staged_changes()?; - - send_layout(rpc_cli, rpc_host, layout).await?; - - println!("All proposed role changes in cluster layout have been canceled."); - Ok(()) -} - pub async fn cmd_config_layout( rpc_cli: &Endpoint, rpc_host: NodeID, diff --git a/src/garage/cli/util.rs b/src/garage/cli/util.rs index 21c14f42..c591cadd 100644 --- a/src/garage/cli/util.rs +++ b/src/garage/cli/util.rs @@ -233,27 +233,6 @@ pub fn print_bucket_info( }; } -pub fn find_matching_node( - cand: impl std::iter::Iterator, - pattern: &str, -) -> Result { - let mut candidates = vec![]; - for c in cand { - if hex::encode(c).starts_with(pattern) && !candidates.contains(&c) { - candidates.push(c); - } - } - if candidates.len() != 1 { - Err(Error::Message(format!( - "{} nodes match '{}'", - candidates.len(), - pattern, - ))) - } else { - Ok(candidates[0]) - } -} - pub fn print_worker_list(wi: HashMap, wlo: WorkerListOpt) { let mut wi = wi.into_iter().collect::>(); wi.sort_by_key(|(tid, info)| { diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs index ccd1886f..8088f019 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli_v2/layout.rs @@ -1,5 +1,5 @@ -use bytesize::ByteSize; -use format_table::format_table; +//use bytesize::ByteSize; +//use format_table::format_table; use garage_util::error::*; @@ -14,21 +14,14 @@ impl Cli { pub async fn layout_command_dispatch(&self, cmd: LayoutOperation) -> Result<(), Error> { match cmd { LayoutOperation::Assign(assign_opt) => self.cmd_assign_role(assign_opt).await, + LayoutOperation::Remove(remove_opt) => self.cmd_remove_role(remove_opt).await, + LayoutOperation::Apply(apply_opt) => self.cmd_apply_layout(apply_opt).await, + LayoutOperation::Revert(revert_opt) => self.cmd_revert_layout(revert_opt).await, // TODO - LayoutOperation::Remove(remove_opt) => { - cli_v1::cmd_remove_role(&self.system_rpc_endpoint, self.rpc_host, remove_opt).await - } LayoutOperation::Show => { cli_v1::cmd_show_layout(&self.system_rpc_endpoint, self.rpc_host).await } - LayoutOperation::Apply(apply_opt) => { - cli_v1::cmd_apply_layout(&self.system_rpc_endpoint, self.rpc_host, apply_opt).await - } - LayoutOperation::Revert(revert_opt) => { - cli_v1::cmd_revert_layout(&self.system_rpc_endpoint, self.rpc_host, revert_opt) - .await - } LayoutOperation::Config(config_opt) => { cli_v1::cmd_config_layout(&self.system_rpc_endpoint, self.rpc_host, config_opt) .await @@ -116,4 +109,64 @@ impl Cli { println!("and `garage layout apply` to enact staged changes."); Ok(()) } + + pub async fn cmd_remove_role(&self, opt: RemoveRoleOpt) -> Result<(), Error> { + let status = self.api_request(GetClusterStatusRequest).await?; + let layout = self.api_request(GetClusterLayoutRequest).await?; + + let all_node_ids_iter = status + .nodes + .iter() + .map(|x| x.id.as_str()) + .chain(layout.roles.iter().map(|x| x.id.as_str())); + + let id = find_matching_node(all_node_ids_iter.clone(), &opt.node_id)?; + + let actions = vec![NodeRoleChange { + id, + action: NodeRoleChangeEnum::Remove { remove: true }, + }]; + + self.api_request(UpdateClusterLayoutRequest(actions)) + .await?; + + println!("Role removal is staged but not yet committed."); + println!("Use `garage layout show` to view staged role changes,"); + println!("and `garage layout apply` to enact staged changes."); + Ok(()) + } + + pub async fn cmd_apply_layout(&self, apply_opt: ApplyLayoutOpt) -> Result<(), Error> { + let missing_version_error = r#" +Please pass the new layout version number to ensure that you are writing the correct version of the cluster layout. +To know the correct value of the new layout version, invoke `garage layout show` and review the proposed changes. + "#; + + let req = ApplyClusterLayoutRequest { + version: apply_opt.version.ok_or_message(missing_version_error)?, + }; + let res = self.api_request(req).await?; + + for line in res.message.iter() { + println!("{}", line); + } + + println!("New cluster layout with updated role assignment has been applied in cluster."); + println!("Data will now be moved around between nodes accordingly."); + + Ok(()) + } + + pub async fn cmd_revert_layout(&self, revert_opt: RevertLayoutOpt) -> Result<(), Error> { + if !revert_opt.yes { + return Err(Error::Message( + "Please add the --yes flag to run the layout revert operation".into(), + )); + } + + self.api_request(RevertClusterLayoutRequest).await?; + + println!("All proposed role changes in cluster layout have been canceled."); + Ok(()) + } } From f37d5d2b08b008eba7b1ee8d84b08d5fddeabf78 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 13:36:25 +0100 Subject: [PATCH 034/192] admin api: convert most bucket operations --- src/api/admin/api.rs | 1 + src/api/admin/bucket.rs | 14 +- src/api/admin/router_v2.rs | 3 +- src/garage/admin/bucket.rs | 449 +------------------------------ src/garage/admin/mod.rs | 1 + src/garage/cli/cmd.rs | 11 - src/garage/cli/util.rs | 137 +--------- src/garage/cli_v2/bucket.rs | 523 ++++++++++++++++++++++++++++++++++++ src/garage/cli_v2/mod.rs | 9 +- src/model/helper/bucket.rs | 73 ++--- 10 files changed, 581 insertions(+), 640 deletions(-) create mode 100644 src/garage/cli_v2/bucket.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 52ecd501..21133f10 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -395,6 +395,7 @@ pub struct BucketLocalAlias { pub struct GetBucketInfoRequest { pub id: Option, pub global_alias: Option, + pub search: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 0cc420ec..d2d75fc0 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -73,16 +73,22 @@ impl EndpointHandler for GetBucketInfoRequest { type Response = GetBucketInfoResponse; async fn handle(self, garage: &Arc) -> Result { - let bucket_id = match (self.id, self.global_alias) { - (Some(id), None) => parse_bucket_id(&id)?, - (None, Some(ga)) => garage + let bucket_id = match (self.id, self.global_alias, self.search) { + (Some(id), None, None) => parse_bucket_id(&id)?, + (None, Some(ga), None) => garage .bucket_helper() .resolve_global_bucket_name(&ga) .await? .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, + (None, None, Some(search)) => { + garage + .bucket_helper() + .admin_get_existing_matching_bucket(&search) + .await? + } _ => { return Err(Error::bad_request( - "Either id or globalAlias must be provided (but not both)", + "Either id, globalAlias or search must be provided (but not several of them)", )); } }; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 29250f39..9d60b312 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -46,7 +46,7 @@ impl AdminApiRequest { POST DeleteKey (query::id), GET ListKeys (), // Bucket endpoints - GET GetBucketInfo (query_opt::id, query_opt::global_alias), + GET GetBucketInfo (query_opt::id, query_opt::global_alias, query_opt::search), GET ListBuckets (), POST CreateBucket (body), POST DeleteBucket (query::id), @@ -141,6 +141,7 @@ impl AdminApiRequest { Ok(AdminApiRequest::GetBucketInfo(GetBucketInfoRequest { id, global_alias, + search: None, })) } Endpoint::CreateBucket => { diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs index 1bdc6086..26d54084 100644 --- a/src/garage/admin/bucket.rs +++ b/src/garage/admin/bucket.rs @@ -1,15 +1,6 @@ -use std::collections::HashMap; use std::fmt::Write; -use garage_util::crdt::*; -use garage_util::time::*; - -use garage_table::*; - -use garage_model::bucket_alias_table::*; -use garage_model::bucket_table::*; use garage_model::helper::error::{Error, OkOrBadRequest}; -use garage_model::permission::*; use crate::cli::*; @@ -18,451 +9,13 @@ use super::*; impl AdminRpcHandler { pub(super) async fn handle_bucket_cmd(&self, cmd: &BucketOperation) -> Result { match cmd { - BucketOperation::List => self.handle_list_buckets().await, - BucketOperation::Info(query) => self.handle_bucket_info(query).await, - BucketOperation::Create(query) => self.handle_create_bucket(&query.name).await, - BucketOperation::Delete(query) => self.handle_delete_bucket(query).await, - BucketOperation::Alias(query) => self.handle_alias_bucket(query).await, - BucketOperation::Unalias(query) => self.handle_unalias_bucket(query).await, - BucketOperation::Allow(query) => self.handle_bucket_allow(query).await, - BucketOperation::Deny(query) => self.handle_bucket_deny(query).await, - BucketOperation::Website(query) => self.handle_bucket_website(query).await, - BucketOperation::SetQuotas(query) => self.handle_bucket_set_quotas(query).await, BucketOperation::CleanupIncompleteUploads(query) => { self.handle_bucket_cleanup_incomplete_uploads(query).await } + _ => unreachable!(), } } - async fn handle_list_buckets(&self) -> Result { - let buckets = self - .garage - .bucket_table - .get_range( - &EmptyKey, - None, - Some(DeletedFilter::NotDeleted), - 10000, - EnumerationOrder::Forward, - ) - .await?; - - Ok(AdminRpc::BucketList(buckets)) - } - - async fn handle_bucket_info(&self, query: &BucketOpt) -> Result { - let bucket_id = self - .garage - .bucket_helper() - .admin_get_existing_matching_bucket(&query.name) - .await?; - - let bucket = self - .garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - - let counters = self - .garage - .object_counter_table - .table - .get(&bucket_id, &EmptyKey) - .await? - .map(|x| x.filtered_values(&self.garage.system.cluster_layout())) - .unwrap_or_default(); - - let mpu_counters = self - .garage - .mpu_counter_table - .table - .get(&bucket_id, &EmptyKey) - .await? - .map(|x| x.filtered_values(&self.garage.system.cluster_layout())) - .unwrap_or_default(); - - let mut relevant_keys = HashMap::new(); - for (k, _) in bucket - .state - .as_option() - .unwrap() - .authorized_keys - .items() - .iter() - { - if let Some(key) = self - .garage - .key_table - .get(&EmptyKey, k) - .await? - .filter(|k| !k.is_deleted()) - { - relevant_keys.insert(k.clone(), key); - } - } - for ((k, _), _, _) in bucket - .state - .as_option() - .unwrap() - .local_aliases - .items() - .iter() - { - if relevant_keys.contains_key(k) { - continue; - } - if let Some(key) = self.garage.key_table.get(&EmptyKey, k).await? { - relevant_keys.insert(k.clone(), key); - } - } - - Ok(AdminRpc::BucketInfo { - bucket, - relevant_keys, - counters, - mpu_counters, - }) - } - - #[allow(clippy::ptr_arg)] - async fn handle_create_bucket(&self, name: &String) -> Result { - if !is_valid_bucket_name(name) { - return Err(Error::BadRequest(format!( - "{}: {}", - name, INVALID_BUCKET_NAME_MESSAGE - ))); - } - - let helper = self.garage.locked_helper().await; - - if let Some(alias) = self.garage.bucket_alias_table.get(&EmptyKey, name).await? { - if alias.state.get().is_some() { - return Err(Error::BadRequest(format!("Bucket {} already exists", name))); - } - } - - // ---- done checking, now commit ---- - - let bucket = Bucket::new(); - self.garage.bucket_table.insert(&bucket).await?; - - helper.set_global_bucket_alias(bucket.id, name).await?; - - Ok(AdminRpc::Ok(format!("Bucket {} was created.", name))) - } - - async fn handle_delete_bucket(&self, query: &DeleteBucketOpt) -> Result { - let helper = self.garage.locked_helper().await; - - let bucket_id = helper - .bucket() - .admin_get_existing_matching_bucket(&query.name) - .await?; - - // Get the alias, but keep in minde here the bucket name - // given in parameter can also be directly the bucket's ID. - // In that case bucket_alias will be None, and - // we can still delete the bucket if it has zero aliases - // (a condition which we try to prevent but that could still happen somehow). - // We just won't try to delete an alias entry because there isn't one. - let bucket_alias = self - .garage - .bucket_alias_table - .get(&EmptyKey, &query.name) - .await?; - - // Check bucket doesn't have other aliases - let mut bucket = helper.bucket().get_existing_bucket(bucket_id).await?; - let bucket_state = bucket.state.as_option().unwrap(); - if bucket_state - .aliases - .items() - .iter() - .filter(|(_, _, active)| *active) - .any(|(name, _, _)| name != &query.name) - { - return Err(Error::BadRequest(format!("Bucket {} still has other global aliases. Use `bucket unalias` to delete them one by one.", query.name))); - } - if bucket_state - .local_aliases - .items() - .iter() - .any(|(_, _, active)| *active) - { - return Err(Error::BadRequest(format!("Bucket {} still has other local aliases. Use `bucket unalias` to delete them one by one.", query.name))); - } - - // Check bucket is empty - if !helper.bucket().is_bucket_empty(bucket_id).await? { - return Err(Error::BadRequest(format!( - "Bucket {} is not empty", - query.name - ))); - } - - if !query.yes { - return Err(Error::BadRequest( - "Add --yes flag to really perform this operation".to_string(), - )); - } - - // --- done checking, now commit --- - // 1. delete authorization from keys that had access - for (key_id, _) in bucket.authorized_keys() { - helper - .set_bucket_key_permissions(bucket.id, key_id, BucketKeyPerm::NO_PERMISSIONS) - .await?; - } - - // 2. delete bucket alias - if bucket_alias.is_some() { - helper - .purge_global_bucket_alias(bucket_id, &query.name) - .await?; - } - - // 3. delete bucket - bucket.state = Deletable::delete(); - self.garage.bucket_table.insert(&bucket).await?; - - Ok(AdminRpc::Ok(format!("Bucket {} was deleted.", query.name))) - } - - async fn handle_alias_bucket(&self, query: &AliasBucketOpt) -> Result { - let helper = self.garage.locked_helper().await; - - let bucket_id = helper - .bucket() - .admin_get_existing_matching_bucket(&query.existing_bucket) - .await?; - - if let Some(key_pattern) = &query.local { - let key = helper.key().get_existing_matching_key(key_pattern).await?; - - helper - .set_local_bucket_alias(bucket_id, &key.key_id, &query.new_name) - .await?; - Ok(AdminRpc::Ok(format!( - "Alias {} now points to bucket {:?} in namespace of key {}", - query.new_name, bucket_id, key.key_id - ))) - } else { - helper - .set_global_bucket_alias(bucket_id, &query.new_name) - .await?; - Ok(AdminRpc::Ok(format!( - "Alias {} now points to bucket {:?}", - query.new_name, bucket_id - ))) - } - } - - async fn handle_unalias_bucket(&self, query: &UnaliasBucketOpt) -> Result { - let helper = self.garage.locked_helper().await; - - if let Some(key_pattern) = &query.local { - let key = helper.key().get_existing_matching_key(key_pattern).await?; - - let bucket_id = key - .state - .as_option() - .unwrap() - .local_aliases - .get(&query.name) - .cloned() - .flatten() - .ok_or_bad_request("Bucket not found")?; - - helper - .unset_local_bucket_alias(bucket_id, &key.key_id, &query.name) - .await?; - - Ok(AdminRpc::Ok(format!( - "Alias {} no longer points to bucket {:?} in namespace of key {}", - &query.name, bucket_id, key.key_id - ))) - } else { - let bucket_id = helper - .bucket() - .resolve_global_bucket_name(&query.name) - .await? - .ok_or_bad_request("Bucket not found")?; - - helper - .unset_global_bucket_alias(bucket_id, &query.name) - .await?; - - Ok(AdminRpc::Ok(format!( - "Alias {} no longer points to bucket {:?}", - &query.name, bucket_id - ))) - } - } - - async fn handle_bucket_allow(&self, query: &PermBucketOpt) -> Result { - let helper = self.garage.locked_helper().await; - - let bucket_id = helper - .bucket() - .admin_get_existing_matching_bucket(&query.bucket) - .await?; - let key = helper - .key() - .get_existing_matching_key(&query.key_pattern) - .await?; - - let allow_read = query.read || key.allow_read(&bucket_id); - let allow_write = query.write || key.allow_write(&bucket_id); - let allow_owner = query.owner || key.allow_owner(&bucket_id); - - helper - .set_bucket_key_permissions( - bucket_id, - &key.key_id, - BucketKeyPerm { - timestamp: now_msec(), - allow_read, - allow_write, - allow_owner, - }, - ) - .await?; - - Ok(AdminRpc::Ok(format!( - "New permissions for {} on {}: read {}, write {}, owner {}.", - &key.key_id, &query.bucket, allow_read, allow_write, allow_owner - ))) - } - - async fn handle_bucket_deny(&self, query: &PermBucketOpt) -> Result { - let helper = self.garage.locked_helper().await; - - let bucket_id = helper - .bucket() - .admin_get_existing_matching_bucket(&query.bucket) - .await?; - let key = helper - .key() - .get_existing_matching_key(&query.key_pattern) - .await?; - - let allow_read = !query.read && key.allow_read(&bucket_id); - let allow_write = !query.write && key.allow_write(&bucket_id); - let allow_owner = !query.owner && key.allow_owner(&bucket_id); - - helper - .set_bucket_key_permissions( - bucket_id, - &key.key_id, - BucketKeyPerm { - timestamp: now_msec(), - allow_read, - allow_write, - allow_owner, - }, - ) - .await?; - - Ok(AdminRpc::Ok(format!( - "New permissions for {} on {}: read {}, write {}, owner {}.", - &key.key_id, &query.bucket, allow_read, allow_write, allow_owner - ))) - } - - async fn handle_bucket_website(&self, query: &WebsiteOpt) -> Result { - let bucket_id = self - .garage - .bucket_helper() - .admin_get_existing_matching_bucket(&query.bucket) - .await?; - - let mut bucket = self - .garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - let bucket_state = bucket.state.as_option_mut().unwrap(); - - if !(query.allow ^ query.deny) { - return Err(Error::BadRequest( - "You must specify exactly one flag, either --allow or --deny".to_string(), - )); - } - - let website = if query.allow { - Some(WebsiteConfig { - index_document: query.index_document.clone(), - error_document: query.error_document.clone(), - }) - } else { - None - }; - - bucket_state.website_config.update(website); - self.garage.bucket_table.insert(&bucket).await?; - - let msg = if query.allow { - format!("Website access allowed for {}", &query.bucket) - } else { - format!("Website access denied for {}", &query.bucket) - }; - - Ok(AdminRpc::Ok(msg)) - } - - async fn handle_bucket_set_quotas(&self, query: &SetQuotasOpt) -> Result { - let bucket_id = self - .garage - .bucket_helper() - .admin_get_existing_matching_bucket(&query.bucket) - .await?; - - let mut bucket = self - .garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - let bucket_state = bucket.state.as_option_mut().unwrap(); - - if query.max_size.is_none() && query.max_objects.is_none() { - return Err(Error::BadRequest( - "You must specify either --max-size or --max-objects (or both) for this command to do something.".to_string(), - )); - } - - let mut quotas = bucket_state.quotas.get().clone(); - - match query.max_size.as_ref().map(String::as_ref) { - Some("none") => quotas.max_size = None, - Some(v) => { - let bs = v - .parse::() - .ok_or_bad_request(format!("Invalid size specified: {}", v))?; - quotas.max_size = Some(bs.as_u64()); - } - _ => (), - } - - match query.max_objects.as_ref().map(String::as_ref) { - Some("none") => quotas.max_objects = None, - Some(v) => { - let mo = v - .parse::() - .ok_or_bad_request(format!("Invalid number specified: {}", v))?; - quotas.max_objects = Some(mo); - } - _ => (), - } - - bucket_state.quotas.update(quotas); - self.garage.bucket_table.insert(&bucket).await?; - - Ok(AdminRpc::Ok(format!( - "Quotas updated for {}", - &query.bucket - ))) - } - async fn handle_bucket_cleanup_incomplete_uploads( &self, query: &CleanupIncompleteUploadsOpt, diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 4c460b8d..aa528965 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -524,6 +524,7 @@ impl AdminRpcHandler { req: &AdminApiRequest, ) -> Result { let req = req.clone(); + info!("Proxied admin API request: {}", req.name()); let res = req.handle(&self.garage).await; match res { Ok(res) => Ok(AdminRpc::ApiOkResponse(res.tagged())), diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index 2b5f93d4..debe7dec 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -17,17 +17,6 @@ pub async fn cmd_admin( AdminRpc::Ok(msg) => { println!("{}", msg); } - AdminRpc::BucketList(bl) => { - print_bucket_list(bl); - } - AdminRpc::BucketInfo { - bucket, - relevant_keys, - counters, - mpu_counters, - } => { - print_bucket_info(&bucket, &relevant_keys, &counters, &mpu_counters); - } AdminRpc::KeyList(kl) => { print_key_list(kl); } diff --git a/src/garage/cli/util.rs b/src/garage/cli/util.rs index c591cadd..acf7923e 100644 --- a/src/garage/cli/util.rs +++ b/src/garage/cli/util.rs @@ -5,51 +5,17 @@ use format_table::format_table; use garage_util::background::*; use garage_util::crdt::*; use garage_util::data::*; -use garage_util::error::*; use garage_util::time::*; use garage_block::manager::BlockResyncErrorInfo; use garage_model::bucket_table::*; use garage_model::key_table::*; -use garage_model::s3::mpu_table::{self, MultipartUpload}; -use garage_model::s3::object_table; +use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::*; use crate::cli::structs::WorkerListOpt; -pub fn print_bucket_list(bl: Vec) { - println!("List of buckets:"); - - let mut table = vec![]; - for bucket in bl { - let aliases = bucket - .aliases() - .iter() - .filter(|(_, _, active)| *active) - .map(|(name, _, _)| name.to_string()) - .collect::>(); - let local_aliases_n = match &bucket - .local_aliases() - .iter() - .filter(|(_, _, active)| *active) - .collect::>()[..] - { - [] => "".into(), - [((k, n), _, _)] => format!("{}:{}", k, n), - s => format!("[{} local aliases]", s.len()), - }; - - table.push(format!( - "\t{}\t{}\t{}", - aliases.join(","), - local_aliases_n, - hex::encode(bucket.id), - )); - } - format_table(table); -} - pub fn print_key_list(kl: Vec<(String, String)>) { println!("List of keys:"); let mut table = vec![]; @@ -132,107 +98,6 @@ pub fn print_key_info(key: &Key, relevant_buckets: &HashMap) { } } -pub fn print_bucket_info( - bucket: &Bucket, - relevant_keys: &HashMap, - counters: &HashMap, - mpu_counters: &HashMap, -) { - let key_name = |k| { - relevant_keys - .get(k) - .map(|k| k.params().unwrap().name.get().as_str()) - .unwrap_or("") - }; - - println!("Bucket: {}", hex::encode(bucket.id)); - match &bucket.state { - Deletable::Deleted => println!("Bucket is deleted."), - Deletable::Present(p) => { - let size = - bytesize::ByteSize::b(*counters.get(object_table::BYTES).unwrap_or(&0) as u64); - println!( - "\nSize: {} ({})", - size.to_string_as(true), - size.to_string_as(false) - ); - println!( - "Objects: {}", - *counters.get(object_table::OBJECTS).unwrap_or(&0) - ); - println!( - "Unfinished uploads (multipart and non-multipart): {}", - *counters.get(object_table::UNFINISHED_UPLOADS).unwrap_or(&0) - ); - println!( - "Unfinished multipart uploads: {}", - *mpu_counters.get(mpu_table::UPLOADS).unwrap_or(&0) - ); - let mpu_size = - bytesize::ByteSize::b(*mpu_counters.get(mpu_table::BYTES).unwrap_or(&0) as u64); - println!( - "Size of unfinished multipart uploads: {} ({})", - mpu_size.to_string_as(true), - mpu_size.to_string_as(false), - ); - - println!("\nWebsite access: {}", p.website_config.get().is_some()); - - let quotas = p.quotas.get(); - if quotas.max_size.is_some() || quotas.max_objects.is_some() { - println!("\nQuotas:"); - if let Some(ms) = quotas.max_size { - let ms = bytesize::ByteSize::b(ms); - println!( - " maximum size: {} ({})", - ms.to_string_as(true), - ms.to_string_as(false) - ); - } - if let Some(mo) = quotas.max_objects { - println!(" maximum number of objects: {}", mo); - } - } - - println!("\nGlobal aliases:"); - for (alias, _, active) in p.aliases.items().iter() { - if *active { - println!(" {}", alias); - } - } - - println!("\nKey-specific aliases:"); - let mut table = vec![]; - for ((key_id, alias), _, active) in p.local_aliases.items().iter() { - if *active { - table.push(format!("\t{} ({})\t{}", key_id, key_name(key_id), alias)); - } - } - format_table(table); - - println!("\nAuthorized keys:"); - let mut table = vec![]; - for (k, perm) in p.authorized_keys.items().iter() { - if !perm.is_any() { - continue; - } - let rflag = if perm.allow_read { "R" } else { " " }; - let wflag = if perm.allow_write { "W" } else { " " }; - let oflag = if perm.allow_owner { "O" } else { " " }; - table.push(format!( - "\t{}{}{}\t{}\t{}", - rflag, - wflag, - oflag, - k, - key_name(k) - )); - } - format_table(table); - } - }; -} - pub fn print_worker_list(wi: HashMap, wlo: WorkerListOpt) { let mut wi = wi.into_iter().collect::>(); wi.sort_by_key(|(tid, info)| { diff --git a/src/garage/cli_v2/bucket.rs b/src/garage/cli_v2/bucket.rs new file mode 100644 index 00000000..837ce783 --- /dev/null +++ b/src/garage/cli_v2/bucket.rs @@ -0,0 +1,523 @@ +//use bytesize::ByteSize; +use format_table::format_table; + +use garage_util::error::*; + +use garage_api::admin::api::*; + +use crate::cli as cli_v1; +use crate::cli::structs::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn cmd_bucket(&self, cmd: BucketOperation) -> Result<(), Error> { + match cmd { + BucketOperation::List => self.cmd_list_buckets().await, + BucketOperation::Info(query) => self.cmd_bucket_info(query).await, + BucketOperation::Create(query) => self.cmd_create_bucket(query).await, + BucketOperation::Delete(query) => self.cmd_delete_bucket(query).await, + BucketOperation::Alias(query) => self.cmd_alias_bucket(query).await, + BucketOperation::Unalias(query) => self.cmd_unalias_bucket(query).await, + BucketOperation::Allow(query) => self.cmd_bucket_allow(query).await, + BucketOperation::Deny(query) => self.cmd_bucket_deny(query).await, + BucketOperation::Website(query) => self.cmd_bucket_website(query).await, + BucketOperation::SetQuotas(query) => self.cmd_bucket_set_quotas(query).await, + + // TODO + x => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::BucketOperation(x), + ) + .await + .ok_or_message("old error"), + } + } + + pub async fn cmd_list_buckets(&self) -> Result<(), Error> { + let buckets = self.api_request(ListBucketsRequest).await?; + + println!("List of buckets:"); + + let mut table = vec![]; + for bucket in buckets.0.iter() { + let local_aliases_n = match &bucket.local_aliases[..] { + [] => "".into(), + [alias] => format!("{}:{}", alias.access_key_id, alias.alias), + s => format!("[{} local aliases]", s.len()), + }; + + table.push(format!( + "\t{}\t{}\t{}", + bucket.global_aliases.join(","), + local_aliases_n, + bucket.id, + )); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_bucket_info(&self, opt: BucketOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.name), + }) + .await?; + + println!("Bucket: {}", bucket.id); + + let size = bytesize::ByteSize::b(bucket.bytes as u64); + println!( + "\nSize: {} ({})", + size.to_string_as(true), + size.to_string_as(false) + ); + println!("Objects: {}", bucket.objects); + println!( + "Unfinished uploads (multipart and non-multipart): {}", + bucket.unfinished_uploads, + ); + println!( + "Unfinished multipart uploads: {}", + bucket.unfinished_multipart_uploads + ); + let mpu_size = bytesize::ByteSize::b(bucket.unfinished_multipart_uploads as u64); + println!( + "Size of unfinished multipart uploads: {} ({})", + mpu_size.to_string_as(true), + mpu_size.to_string_as(false), + ); + + println!("\nWebsite access: {}", bucket.website_access); + + if bucket.quotas.max_size.is_some() || bucket.quotas.max_objects.is_some() { + println!("\nQuotas:"); + if let Some(ms) = bucket.quotas.max_size { + let ms = bytesize::ByteSize::b(ms); + println!( + " maximum size: {} ({})", + ms.to_string_as(true), + ms.to_string_as(false) + ); + } + if let Some(mo) = bucket.quotas.max_objects { + println!(" maximum number of objects: {}", mo); + } + } + + println!("\nGlobal aliases:"); + for alias in bucket.global_aliases { + println!(" {}", alias); + } + + println!("\nKey-specific aliases:"); + let mut table = vec![]; + for key in bucket.keys.iter() { + for alias in key.bucket_local_aliases.iter() { + table.push(format!("\t{} ({})\t{}", key.access_key_id, key.name, alias)); + } + } + format_table(table); + + println!("\nAuthorized keys:"); + let mut table = vec![]; + for key in bucket.keys.iter() { + if !(key.permissions.read || key.permissions.write || key.permissions.owner) { + continue; + } + let rflag = if key.permissions.read { "R" } else { " " }; + let wflag = if key.permissions.write { "W" } else { " " }; + let oflag = if key.permissions.owner { "O" } else { " " }; + table.push(format!( + "\t{}{}{}\t{}\t{}", + rflag, wflag, oflag, key.access_key_id, key.name + )); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_create_bucket(&self, opt: BucketOpt) -> Result<(), Error> { + self.api_request(CreateBucketRequest { + global_alias: Some(opt.name.clone()), + local_alias: None, + }) + .await?; + + println!("Bucket {} was created.", opt.name); + + Ok(()) + } + + pub async fn cmd_delete_bucket(&self, opt: DeleteBucketOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.name.clone()), + }) + .await?; + + // CLI-only checks: the bucket must not have other aliases + if bucket + .global_aliases + .iter() + .find(|a| **a != opt.name) + .is_some() + { + return Err(Error::Message(format!("Bucket {} still has other global aliases. Use `bucket unalias` to delete them one by one.", opt.name))); + } + + if bucket + .keys + .iter() + .any(|k| !k.bucket_local_aliases.is_empty()) + { + return Err(Error::Message(format!("Bucket {} still has other local aliases. Use `bucket unalias` to delete them one by one.", opt.name))); + } + + if !opt.yes { + println!("About to delete bucket {}.", bucket.id); + return Err(Error::Message( + "Add --yes flag to really perform this operation".to_string(), + )); + } + + self.api_request(DeleteBucketRequest { + id: bucket.id.clone(), + }) + .await?; + + println!("Bucket {} has been deleted.", bucket.id); + + Ok(()) + } + + pub async fn cmd_alias_bucket(&self, opt: AliasBucketOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.existing_bucket.clone()), + }) + .await?; + + if let Some(key_pat) = &opt.local { + let key = self + .api_request(GetKeyInfoRequest { + search: Some(key_pat.clone()), + id: None, + show_secret_key: false, + }) + .await?; + + self.api_request(AddBucketAliasRequest { + bucket_id: bucket.id.clone(), + alias: BucketAliasEnum::Local { + local_alias: opt.new_name.clone(), + access_key_id: key.access_key_id.clone(), + }, + }) + .await?; + + println!( + "Alias {} now points to bucket {:.16} in namespace of key {}", + opt.new_name, bucket.id, key.access_key_id + ) + } else { + self.api_request(AddBucketAliasRequest { + bucket_id: bucket.id.clone(), + alias: BucketAliasEnum::Global { + global_alias: opt.new_name.clone(), + }, + }) + .await?; + + println!( + "Alias {} now points to bucket {:.16}", + opt.new_name, bucket.id + ) + } + + Ok(()) + } + + pub async fn cmd_unalias_bucket(&self, opt: UnaliasBucketOpt) -> Result<(), Error> { + if let Some(key_pat) = &opt.local { + let key = self + .api_request(GetKeyInfoRequest { + search: Some(key_pat.clone()), + id: None, + show_secret_key: false, + }) + .await?; + + let bucket = key + .buckets + .iter() + .find(|x| x.local_aliases.contains(&opt.name)) + .ok_or_message(format!( + "No bucket called {} in namespace of key {}", + opt.name, key.access_key_id + ))?; + + self.api_request(RemoveBucketAliasRequest { + bucket_id: bucket.id.clone(), + alias: BucketAliasEnum::Local { + access_key_id: key.access_key_id.clone(), + local_alias: opt.name.clone(), + }, + }) + .await?; + + println!( + "Alias {} no longer points to bucket {:.16} in namespace of key {}", + &opt.name, bucket.id, key.access_key_id + ) + } else { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: Some(opt.name.clone()), + search: None, + }) + .await?; + + self.api_request(RemoveBucketAliasRequest { + bucket_id: bucket.id.clone(), + alias: BucketAliasEnum::Global { + global_alias: opt.name.clone(), + }, + }) + .await?; + + println!( + "Alias {} no longer points to bucket {:.16}", + opt.name, bucket.id + ) + } + + Ok(()) + } + + pub async fn cmd_bucket_allow(&self, opt: PermBucketOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.bucket.clone()), + }) + .await?; + + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern.clone()), + show_secret_key: false, + }) + .await?; + + self.api_request(AllowBucketKeyRequest(BucketKeyPermChangeRequest { + bucket_id: bucket.id.clone(), + access_key_id: key.access_key_id.clone(), + permissions: ApiBucketKeyPerm { + read: opt.read, + write: opt.write, + owner: opt.owner, + }, + })) + .await?; + + let new_bucket = self + .api_request(GetBucketInfoRequest { + id: Some(bucket.id), + global_alias: None, + search: None, + }) + .await?; + + if let Some(new_key) = new_bucket + .keys + .iter() + .find(|k| k.access_key_id == key.access_key_id) + { + println!( + "New permissions for key {} on bucket {:.16}:\n read {}\n write {}\n owner {}", + key.access_key_id, + new_bucket.id, + new_key.permissions.read, + new_key.permissions.write, + new_key.permissions.owner + ); + } else { + println!( + "Access key {} has no permissions on bucket {:.16}", + key.access_key_id, new_bucket.id + ); + } + + Ok(()) + } + + pub async fn cmd_bucket_deny(&self, opt: PermBucketOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.bucket.clone()), + }) + .await?; + + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern.clone()), + show_secret_key: false, + }) + .await?; + + self.api_request(DenyBucketKeyRequest(BucketKeyPermChangeRequest { + bucket_id: bucket.id.clone(), + access_key_id: key.access_key_id.clone(), + permissions: ApiBucketKeyPerm { + read: opt.read, + write: opt.write, + owner: opt.owner, + }, + })) + .await?; + + let new_bucket = self + .api_request(GetBucketInfoRequest { + id: Some(bucket.id), + global_alias: None, + search: None, + }) + .await?; + + if let Some(new_key) = new_bucket + .keys + .iter() + .find(|k| k.access_key_id == key.access_key_id) + { + println!( + "New permissions for key {} on bucket {:.16}:\n read {}\n write {}\n owner {}", + key.access_key_id, + new_bucket.id, + new_key.permissions.read, + new_key.permissions.write, + new_key.permissions.owner + ); + } else { + println!( + "Access key {} no longer has permissions on bucket {:.16}", + key.access_key_id, new_bucket.id + ); + } + + Ok(()) + } + + pub async fn cmd_bucket_website(&self, opt: WebsiteOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.bucket.clone()), + }) + .await?; + + if !(opt.allow ^ opt.deny) { + return Err(Error::Message( + "You must specify exactly one flag, either --allow or --deny".to_string(), + )); + } + + let wa = if opt.allow { + UpdateBucketWebsiteAccess { + enabled: true, + index_document: Some(opt.index_document.clone()), + error_document: opt + .error_document + .or(bucket.website_config.and_then(|x| x.error_document.clone())), + } + } else { + UpdateBucketWebsiteAccess { + enabled: false, + index_document: None, + error_document: None, + } + }; + + self.api_request(UpdateBucketRequest { + id: bucket.id, + body: UpdateBucketRequestBody { + website_access: Some(wa), + quotas: None, + }, + }) + .await?; + + if opt.allow { + println!("Website access allowed for {}", &opt.bucket); + } else { + println!("Website access denied for {}", &opt.bucket); + } + + Ok(()) + } + + pub async fn cmd_bucket_set_quotas(&self, opt: SetQuotasOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.bucket.clone()), + }) + .await?; + + if opt.max_size.is_none() && opt.max_objects.is_none() { + return Err(Error::Message( + "You must specify either --max-size or --max-objects (or both) for this command to do something.".to_string(), + )); + } + + let new_quotas = ApiBucketQuotas { + max_size: match opt.max_size.as_deref() { + Some("none") => None, + Some(v) => Some( + v.parse::() + .ok_or_message(format!("Invalid size specified: {}", v))? + .as_u64(), + ), + None => bucket.quotas.max_size, + }, + max_objects: match opt.max_objects.as_deref() { + Some("none") => None, + Some(v) => Some( + v.parse::() + .ok_or_message(format!("Invalid number: {}", v))?, + ), + None => bucket.quotas.max_objects, + }, + }; + + self.api_request(UpdateBucketRequest { + id: bucket.id.clone(), + body: UpdateBucketRequestBody { + website_access: None, + quotas: Some(new_quotas), + }, + }) + .await?; + + println!("Quotas updated for bucket {:.16}", bucket.id); + + Ok(()) + } +} diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 2fe45e29..24ff6f72 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -1,5 +1,6 @@ pub mod util; +pub mod bucket; pub mod cluster; pub mod layout; @@ -35,15 +36,9 @@ impl Cli { self.cmd_connect(connect_opt).await } Command::Layout(layout_opt) => self.layout_command_dispatch(layout_opt).await, + Command::Bucket(bo) => self.cmd_bucket(bo).await, // TODO - Command::Bucket(bo) => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::BucketOperation(bo), - ) - .await - .ok_or_message("xoxo"), Command::Key(ko) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, self.rpc_host, diff --git a/src/model/helper/bucket.rs b/src/model/helper/bucket.rs index e5506d7e..fe86c9d9 100644 --- a/src/model/helper/bucket.rs +++ b/src/model/helper/bucket.rs @@ -73,41 +73,48 @@ impl<'a> BucketHelper<'a> { pattern: &String, ) -> Result { if let Some(uuid) = self.resolve_global_bucket_name(pattern).await? { - return Ok(uuid); - } else if pattern.len() >= 2 { - let hexdec = pattern - .get(..pattern.len() & !1) - .and_then(|x| hex::decode(x).ok()); - if let Some(hex) = hexdec { - let mut start = [0u8; 32]; - start - .as_mut_slice() - .get_mut(..hex.len()) - .ok_or_bad_request("invalid length")? - .copy_from_slice(&hex); - let mut candidates = self - .0 - .bucket_table - .get_range( - &EmptyKey, - Some(start.into()), - Some(DeletedFilter::NotDeleted), - 10, - EnumerationOrder::Forward, - ) - .await? - .into_iter() - .collect::>(); - candidates.retain(|x| hex::encode(x.id).starts_with(pattern)); - if candidates.len() == 1 { - return Ok(candidates.into_iter().next().unwrap().id); - } + Ok(uuid) + } else { + let hexdec = if pattern.len() >= 2 { + pattern + .get(..pattern.len() & !1) + .and_then(|x| hex::decode(x).ok()) + } else { + None + }; + let hex = hexdec.ok_or_else(|| Error::NoSuchBucket(pattern.clone()))?; + + let mut start = [0u8; 32]; + start + .as_mut_slice() + .get_mut(..hex.len()) + .ok_or_bad_request("invalid length")? + .copy_from_slice(&hex); + let mut candidates = self + .0 + .bucket_table + .get_range( + &EmptyKey, + Some(start.into()), + Some(DeletedFilter::NotDeleted), + 10, + EnumerationOrder::Forward, + ) + .await? + .into_iter() + .collect::>(); + candidates.retain(|x| hex::encode(x.id).starts_with(pattern)); + if candidates.is_empty() { + Err(Error::NoSuchBucket(pattern.clone())) + } else if candidates.len() == 1 { + Ok(candidates.into_iter().next().unwrap().id) + } else { + Err(Error::BadRequest(format!( + "Several matching buckets: {}", + pattern + ))) } } - Err(Error::BadRequest(format!( - "Bucket not found / several matching buckets: {}", - pattern - ))) } /// Returns a Bucket if it is present in bucket table, From 076ce04fe53123c5046f356e2b164a8093be2dfe Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 15:38:22 +0100 Subject: [PATCH 035/192] fix garage status output --- src/garage/cli_v2/cluster.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/garage/cli_v2/cluster.rs b/src/garage/cli_v2/cluster.rs index 0b5b9559..fa63960d 100644 --- a/src/garage/cli_v2/cluster.rs +++ b/src/garage/cli_v2/cluster.rs @@ -71,7 +71,7 @@ impl Cli { _ => "NO ROLE ASSIGNED", }; healthy_nodes.push(format!( - "{id:?}\t{h}\t{addr}\t\t\t{new_role}", + "{id:.16}\t{h}\t{addr}\t\t\t{new_role}", id = adv.id, h = host, addr = addr, From f8c6a8373d630311a18e9af011724181be68e5e1 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 16:12:16 +0100 Subject: [PATCH 036/192] convert cli key operations to admin rpc --- src/garage/admin/key.rs | 161 ------------------------- src/garage/admin/mod.rs | 14 --- src/garage/cli/cmd.rs | 6 - src/garage/cli/util.rs | 85 ------------- src/garage/cli_v2/cluster.rs | 52 +++----- src/garage/cli_v2/key.rs | 227 +++++++++++++++++++++++++++++++++++ src/garage/cli_v2/mod.rs | 9 +- 7 files changed, 247 insertions(+), 307 deletions(-) delete mode 100644 src/garage/admin/key.rs create mode 100644 src/garage/cli_v2/key.rs diff --git a/src/garage/admin/key.rs b/src/garage/admin/key.rs deleted file mode 100644 index bd010d2c..00000000 --- a/src/garage/admin/key.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::collections::HashMap; - -use garage_table::*; - -use garage_model::helper::error::*; -use garage_model::key_table::*; - -use crate::cli::*; - -use super::*; - -impl AdminRpcHandler { - pub(super) async fn handle_key_cmd(&self, cmd: &KeyOperation) -> Result { - match cmd { - KeyOperation::List => self.handle_list_keys().await, - KeyOperation::Info(query) => self.handle_key_info(query).await, - KeyOperation::Create(query) => self.handle_create_key(query).await, - KeyOperation::Rename(query) => self.handle_rename_key(query).await, - KeyOperation::Delete(query) => self.handle_delete_key(query).await, - KeyOperation::Allow(query) => self.handle_allow_key(query).await, - KeyOperation::Deny(query) => self.handle_deny_key(query).await, - KeyOperation::Import(query) => self.handle_import_key(query).await, - } - } - - async fn handle_list_keys(&self) -> Result { - let key_ids = self - .garage - .key_table - .get_range( - &EmptyKey, - None, - Some(KeyFilter::Deleted(DeletedFilter::NotDeleted)), - 10000, - EnumerationOrder::Forward, - ) - .await? - .iter() - .map(|k| (k.key_id.to_string(), k.params().unwrap().name.get().clone())) - .collect::>(); - Ok(AdminRpc::KeyList(key_ids)) - } - - async fn handle_key_info(&self, query: &KeyInfoOpt) -> Result { - let mut key = self - .garage - .key_helper() - .get_existing_matching_key(&query.key_pattern) - .await?; - - if !query.show_secret { - key.state.as_option_mut().unwrap().secret_key = "(redacted)".into(); - } - - self.key_info_result(key).await - } - - async fn handle_create_key(&self, query: &KeyNewOpt) -> Result { - let key = Key::new(&query.name); - self.garage.key_table.insert(&key).await?; - self.key_info_result(key).await - } - - async fn handle_rename_key(&self, query: &KeyRenameOpt) -> Result { - let mut key = self - .garage - .key_helper() - .get_existing_matching_key(&query.key_pattern) - .await?; - key.params_mut() - .unwrap() - .name - .update(query.new_name.clone()); - self.garage.key_table.insert(&key).await?; - self.key_info_result(key).await - } - - async fn handle_delete_key(&self, query: &KeyDeleteOpt) -> Result { - let helper = self.garage.locked_helper().await; - - let mut key = helper - .key() - .get_existing_matching_key(&query.key_pattern) - .await?; - - if !query.yes { - return Err(Error::BadRequest( - "Add --yes flag to really perform this operation".to_string(), - )); - } - - helper.delete_key(&mut key).await?; - - Ok(AdminRpc::Ok(format!( - "Key {} was deleted successfully.", - key.key_id - ))) - } - - async fn handle_allow_key(&self, query: &KeyPermOpt) -> Result { - let mut key = self - .garage - .key_helper() - .get_existing_matching_key(&query.key_pattern) - .await?; - if query.create_bucket { - key.params_mut().unwrap().allow_create_bucket.update(true); - } - self.garage.key_table.insert(&key).await?; - self.key_info_result(key).await - } - - async fn handle_deny_key(&self, query: &KeyPermOpt) -> Result { - let mut key = self - .garage - .key_helper() - .get_existing_matching_key(&query.key_pattern) - .await?; - if query.create_bucket { - key.params_mut().unwrap().allow_create_bucket.update(false); - } - self.garage.key_table.insert(&key).await?; - self.key_info_result(key).await - } - - async fn handle_import_key(&self, query: &KeyImportOpt) -> Result { - if !query.yes { - return Err(Error::BadRequest("This command is intended to re-import keys that were previously generated by Garage. If you want to create a new key, use `garage key new` instead. Add the --yes flag if you really want to re-import a key.".to_string())); - } - - let prev_key = self.garage.key_table.get(&EmptyKey, &query.key_id).await?; - if prev_key.is_some() { - return Err(Error::BadRequest(format!("Key {} already exists in data store. Even if it is deleted, we can't let you create a new key with the same ID. Sorry.", query.key_id))); - } - - let imported_key = Key::import(&query.key_id, &query.secret_key, &query.name) - .ok_or_bad_request("Invalid key format")?; - self.garage.key_table.insert(&imported_key).await?; - - self.key_info_result(imported_key).await - } - - async fn key_info_result(&self, key: Key) -> Result { - let mut relevant_buckets = HashMap::new(); - - for (id, _) in key - .state - .as_option() - .unwrap() - .authorized_buckets - .items() - .iter() - { - if let Some(b) = self.garage.bucket_table.get(&EmptyKey, id).await? { - relevant_buckets.insert(*id, b); - } - } - - Ok(AdminRpc::KeyInfo(key, relevant_buckets)) - } -} diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index aa528965..1888a208 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -1,6 +1,5 @@ mod block; mod bucket; -mod key; use std::collections::HashMap; use std::fmt::Write; @@ -23,10 +22,8 @@ use garage_rpc::*; use garage_block::manager::BlockResyncErrorInfo; -use garage_model::bucket_table::*; use garage_model::garage::Garage; use garage_model::helper::error::{Error, OkOrBadRequest}; -use garage_model::key_table::*; use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::Version; @@ -43,7 +40,6 @@ pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; #[allow(clippy::large_enum_variant)] pub enum AdminRpc { BucketOperation(BucketOperation), - KeyOperation(KeyOperation), LaunchRepair(RepairOpt), Stats(StatsOpt), Worker(WorkerOperation), @@ -52,15 +48,6 @@ pub enum AdminRpc { // Replies Ok(String), - BucketList(Vec), - BucketInfo { - bucket: Bucket, - relevant_keys: HashMap, - counters: HashMap, - mpu_counters: HashMap, - }, - KeyList(Vec<(String, String)>), - KeyInfo(Key, HashMap), WorkerList( HashMap, WorkerListOpt, @@ -546,7 +533,6 @@ impl EndpointHandler for AdminRpcHandler { ) -> Result { match message { AdminRpc::BucketOperation(bo) => self.handle_bucket_cmd(bo).await, - AdminRpc::KeyOperation(ko) => self.handle_key_cmd(ko).await, AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, AdminRpc::Stats(opt) => self.handle_stats(opt.clone()).await, AdminRpc::Worker(wo) => self.handle_worker_cmd(wo).await, diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index debe7dec..a6540c65 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -17,12 +17,6 @@ pub async fn cmd_admin( AdminRpc::Ok(msg) => { println!("{}", msg); } - AdminRpc::KeyList(kl) => { - print_key_list(kl); - } - AdminRpc::KeyInfo(key, rb) => { - print_key_info(&key, &rb); - } AdminRpc::WorkerList(wi, wlo) => { print_worker_list(wi, wlo); } diff --git a/src/garage/cli/util.rs b/src/garage/cli/util.rs index acf7923e..a3a1480e 100644 --- a/src/garage/cli/util.rs +++ b/src/garage/cli/util.rs @@ -3,101 +3,16 @@ use std::time::Duration; use format_table::format_table; use garage_util::background::*; -use garage_util::crdt::*; use garage_util::data::*; use garage_util::time::*; use garage_block::manager::BlockResyncErrorInfo; -use garage_model::bucket_table::*; -use garage_model::key_table::*; use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::*; use crate::cli::structs::WorkerListOpt; -pub fn print_key_list(kl: Vec<(String, String)>) { - println!("List of keys:"); - let mut table = vec![]; - for key in kl { - table.push(format!("\t{}\t{}", key.0, key.1)); - } - format_table(table); -} - -pub fn print_key_info(key: &Key, relevant_buckets: &HashMap) { - let bucket_global_aliases = |b: &Uuid| { - if let Some(bucket) = relevant_buckets.get(b) { - if let Some(p) = bucket.state.as_option() { - return p - .aliases - .items() - .iter() - .filter(|(_, _, active)| *active) - .map(|(a, _, _)| a.clone()) - .collect::>() - .join(", "); - } - } - - "".to_string() - }; - - match &key.state { - Deletable::Present(p) => { - println!("Key name: {}", p.name.get()); - println!("Key ID: {}", key.key_id); - println!("Secret key: {}", p.secret_key); - println!("Can create buckets: {}", p.allow_create_bucket.get()); - println!("\nKey-specific bucket aliases:"); - let mut table = vec![]; - for (alias_name, _, alias) in p.local_aliases.items().iter() { - if let Some(bucket_id) = alias { - table.push(format!( - "\t{}\t{}\t{}", - alias_name, - bucket_global_aliases(bucket_id), - hex::encode(bucket_id) - )); - } - } - format_table(table); - - println!("\nAuthorized buckets:"); - let mut table = vec![]; - for (bucket_id, perm) in p.authorized_buckets.items().iter() { - if !perm.is_any() { - continue; - } - let rflag = if perm.allow_read { "R" } else { " " }; - let wflag = if perm.allow_write { "W" } else { " " }; - let oflag = if perm.allow_owner { "O" } else { " " }; - let local_aliases = p - .local_aliases - .items() - .iter() - .filter(|(_, _, a)| *a == Some(*bucket_id)) - .map(|(a, _, _)| a.clone()) - .collect::>() - .join(", "); - table.push(format!( - "\t{}{}{}\t{}\t{}\t{:?}", - rflag, - wflag, - oflag, - bucket_global_aliases(bucket_id), - local_aliases, - bucket_id - )); - } - format_table(table); - } - Deletable::Deleted => { - println!("Key {} is deleted.", key.key_id); - } - } -} - pub fn print_worker_list(wi: HashMap, wlo: WorkerListOpt) { let mut wi = wi.into_iter().collect::>(); wi.sort_by_key(|(tid, info)| { diff --git a/src/garage/cli_v2/cluster.rs b/src/garage/cli_v2/cluster.rs index fa63960d..adaf9a25 100644 --- a/src/garage/cli_v2/cluster.rs +++ b/src/garage/cli_v2/cluster.rs @@ -43,41 +43,25 @@ impl Cli { capacity = capacity_string(cfg.capacity), data_avail = data_avail, )); + } else if adv.draining { + healthy_nodes.push(format!( + "{id:.16}\t{host}\t{addr}\t\t\tdraining metadata...", + id = adv.id, + host = host, + addr = addr, + )); } else { - /* - let prev_role = layout - .versions - .iter() - .rev() - .find_map(|x| match x.roles.get(&adv.id) { - Some(NodeRoleV(Some(cfg))) => Some(cfg), - _ => None, - }); - */ - let prev_role = Option::::None; //TODO - if let Some(cfg) = prev_role { - healthy_nodes.push(format!( - "{id:.16}\t{host}\t{addr}\t[{tags}]\t{zone}\tdraining metadata...", - id = adv.id, - host = host, - addr = addr, - tags = cfg.tags.join(","), - zone = cfg.zone, - )); - } else { - let new_role = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) - { - Some(_) => "pending...", - _ => "NO ROLE ASSIGNED", - }; - healthy_nodes.push(format!( - "{id:.16}\t{h}\t{addr}\t\t\t{new_role}", - id = adv.id, - h = host, - addr = addr, - new_role = new_role, - )); - } + let new_role = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) { + Some(_) => "pending...", + _ => "NO ROLE ASSIGNED", + }; + healthy_nodes.push(format!( + "{id:.16}\t{h}\t{addr}\t\t\t{new_role}", + id = adv.id, + h = host, + addr = addr, + new_role = new_role, + )); } } format_table(healthy_nodes); diff --git a/src/garage/cli_v2/key.rs b/src/garage/cli_v2/key.rs new file mode 100644 index 00000000..ff403a9a --- /dev/null +++ b/src/garage/cli_v2/key.rs @@ -0,0 +1,227 @@ +use format_table::format_table; + +use garage_util::error::*; + +use garage_api::admin::api::*; + +use crate::cli::structs::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn cmd_key(&self, cmd: KeyOperation) -> Result<(), Error> { + match cmd { + KeyOperation::List => self.cmd_list_keys().await, + KeyOperation::Info(query) => self.cmd_key_info(query).await, + KeyOperation::Create(query) => self.cmd_create_key(query).await, + KeyOperation::Rename(query) => self.cmd_rename_key(query).await, + KeyOperation::Delete(query) => self.cmd_delete_key(query).await, + KeyOperation::Allow(query) => self.cmd_allow_key(query).await, + KeyOperation::Deny(query) => self.cmd_deny_key(query).await, + KeyOperation::Import(query) => self.cmd_import_key(query).await, + } + } + + pub async fn cmd_list_keys(&self) -> Result<(), Error> { + let keys = self.api_request(ListKeysRequest).await?; + + println!("List of keys:"); + let mut table = vec![]; + for key in keys.0.iter() { + table.push(format!("\t{}\t{}", key.id, key.name)); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_key_info(&self, opt: KeyInfoOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: opt.show_secret, + }) + .await?; + + print_key_info(&key); + + Ok(()) + } + + pub async fn cmd_create_key(&self, opt: KeyNewOpt) -> Result<(), Error> { + let key = self + .api_request(CreateKeyRequest { + name: Some(opt.name), + }) + .await?; + + print_key_info(&key.0); + + Ok(()) + } + + pub async fn cmd_rename_key(&self, opt: KeyRenameOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: false, + }) + .await?; + + let new_key = self + .api_request(UpdateKeyRequest { + id: key.access_key_id, + body: UpdateKeyRequestBody { + name: Some(opt.new_name), + allow: None, + deny: None, + }, + }) + .await?; + + print_key_info(&new_key.0); + + Ok(()) + } + + pub async fn cmd_delete_key(&self, opt: KeyDeleteOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: false, + }) + .await?; + + if !opt.yes { + println!("About to delete key {}...", key.access_key_id); + return Err(Error::Message( + "Add --yes flag to really perform this operation".to_string(), + )); + } + + self.api_request(DeleteKeyRequest { + id: key.access_key_id.clone(), + }) + .await?; + + println!("Access key {} has been deleted.", key.access_key_id); + + Ok(()) + } + + pub async fn cmd_allow_key(&self, opt: KeyPermOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: false, + }) + .await?; + + let new_key = self + .api_request(UpdateKeyRequest { + id: key.access_key_id, + body: UpdateKeyRequestBody { + name: None, + allow: Some(KeyPerm { + create_bucket: opt.create_bucket, + }), + deny: None, + }, + }) + .await?; + + print_key_info(&new_key.0); + + Ok(()) + } + + pub async fn cmd_deny_key(&self, opt: KeyPermOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: false, + }) + .await?; + + let new_key = self + .api_request(UpdateKeyRequest { + id: key.access_key_id, + body: UpdateKeyRequestBody { + name: None, + allow: None, + deny: Some(KeyPerm { + create_bucket: opt.create_bucket, + }), + }, + }) + .await?; + + print_key_info(&new_key.0); + + Ok(()) + } + + pub async fn cmd_import_key(&self, opt: KeyImportOpt) -> Result<(), Error> { + if !opt.yes { + return Err(Error::Message("This command is intended to re-import keys that were previously generated by Garage. If you want to create a new key, use `garage key new` instead. Add the --yes flag if you really want to re-import a key.".to_string())); + } + + let new_key = self + .api_request(ImportKeyRequest { + name: Some(opt.name), + access_key_id: opt.key_id, + secret_access_key: opt.secret_key, + }) + .await?; + + print_key_info(&new_key.0); + + Ok(()) + } +} + +fn print_key_info(key: &GetKeyInfoResponse) { + println!("Key name: {}", key.name); + println!("Key ID: {}", key.access_key_id); + println!( + "Secret key: {}", + key.secret_access_key.as_deref().unwrap_or("(redacted)") + ); + println!("Can create buckets: {}", key.permissions.create_bucket); + + println!("\nKey-specific bucket aliases:"); + let mut table = vec![]; + for bucket in key.buckets.iter() { + for la in bucket.local_aliases.iter() { + table.push(format!( + "\t{}\t{}\t{}", + la, + bucket.global_aliases.join(","), + bucket.id + )); + } + } + format_table(table); + + println!("\nAuthorized buckets:"); + let mut table = vec![]; + for bucket in key.buckets.iter() { + let rflag = if bucket.permissions.read { "R" } else { " " }; + let wflag = if bucket.permissions.write { "W" } else { " " }; + let oflag = if bucket.permissions.owner { "O" } else { " " }; + table.push(format!( + "\t{}{}{}\t{}\t{}\t{:.16}", + rflag, + wflag, + oflag, + bucket.global_aliases.join(","), + bucket.local_aliases.join(","), + bucket.id + )); + } + format_table(table); +} diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 24ff6f72..e6d2d8c6 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -2,6 +2,7 @@ pub mod util; pub mod bucket; pub mod cluster; +pub mod key; pub mod layout; use std::collections::{HashMap, HashSet}; @@ -37,15 +38,9 @@ impl Cli { } Command::Layout(layout_opt) => self.layout_command_dispatch(layout_opt).await, Command::Bucket(bo) => self.cmd_bucket(bo).await, + Command::Key(ko) => self.cmd_key(ko).await, // TODO - Command::Key(ko) => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::KeyOperation(ko), - ) - .await - .ok_or_message("xoxo"), Command::Repair(ro) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, self.rpc_host, From ebc0e9319e8e0f8d77eb538d8d0356189597acaa Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 16:17:35 +0100 Subject: [PATCH 037/192] cli_v2: error messages --- src/garage/cli_v2/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index e6d2d8c6..b51ed67f 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -47,11 +47,11 @@ impl Cli { AdminRpc::LaunchRepair(ro), ) .await - .ok_or_message("xoxo"), + .ok_or_message("cli_v1"), Command::Stats(so) => { cli_v1::cmd_admin(&self.admin_rpc_endpoint, self.rpc_host, AdminRpc::Stats(so)) .await - .ok_or_message("xoxo") + .ok_or_message("cli_v1") } Command::Worker(wo) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, @@ -59,21 +59,21 @@ impl Cli { AdminRpc::Worker(wo), ) .await - .ok_or_message("xoxo"), + .ok_or_message("cli_v1"), Command::Block(bo) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, self.rpc_host, AdminRpc::BlockOperation(bo), ) .await - .ok_or_message("xoxo"), + .ok_or_message("cli_v1"), Command::Meta(mo) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, self.rpc_host, AdminRpc::MetaOperation(mo), ) .await - .ok_or_message("xoxo"), + .ok_or_message("cli_v1"), _ => unreachable!(), } @@ -91,7 +91,7 @@ impl Cli { .admin_rpc_endpoint .call(&self.rpc_host, AdminRpc::ApiRequest(req), PRIO_NORMAL) .await? - .ok_or_message("xoxo")? + .ok_or_message("rpc")? { AdminRpc::ApiOkResponse(resp) => ::Response::try_from(resp) .map_err(|_| Error::Message(format!("{} returned unexpected response", req_name))), From 3caea5fc06a36b9e2f446c263b29948de431f30f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 16:24:55 +0100 Subject: [PATCH 038/192] cli_v2: merge util.rs into layout.rs --- src/garage/cli_v2/cluster.rs | 2 +- src/garage/cli_v2/layout.rs | 118 ++++++++++++++++++++++++++++++++++- src/garage/cli_v2/mod.rs | 2 - src/garage/cli_v2/util.rs | 115 ---------------------------------- 4 files changed, 116 insertions(+), 121 deletions(-) delete mode 100644 src/garage/cli_v2/util.rs diff --git a/src/garage/cli_v2/cluster.rs b/src/garage/cli_v2/cluster.rs index adaf9a25..e6ba2428 100644 --- a/src/garage/cli_v2/cluster.rs +++ b/src/garage/cli_v2/cluster.rs @@ -5,7 +5,7 @@ use garage_util::error::*; use garage_api::admin::api::*; use crate::cli::structs::*; -use crate::cli_v2::util::*; +use crate::cli_v2::layout::*; use crate::cli_v2::*; impl Cli { diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs index 8088f019..d44771c7 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli_v2/layout.rs @@ -1,5 +1,5 @@ -//use bytesize::ByteSize; -//use format_table::format_table; +use bytesize::ByteSize; +use format_table::format_table; use garage_util::error::*; @@ -7,7 +7,6 @@ use garage_api::admin::api::*; use crate::cli::layout as cli_v1; use crate::cli::structs::*; -use crate::cli_v2::util::*; use crate::cli_v2::*; impl Cli { @@ -170,3 +169,116 @@ To know the correct value of the new layout version, invoke `garage layout show` Ok(()) } } + +// -------------------------- +// ---- helper functions ---- +// -------------------------- + +pub fn capacity_string(v: Option) -> String { + match v { + Some(c) => ByteSize::b(c).to_string_as(false), + None => "gateway".to_string(), + } +} + +pub fn get_staged_or_current_role( + id: &str, + layout: &GetClusterLayoutResponse, +) -> Option { + for node in layout.staged_role_changes.iter() { + if node.id == id { + return match &node.action { + NodeRoleChangeEnum::Remove { .. } => None, + NodeRoleChangeEnum::Update { + zone, + capacity, + tags, + } => Some(NodeRoleResp { + id: id.to_string(), + zone: zone.to_string(), + capacity: *capacity, + tags: tags.clone(), + }), + }; + } + } + + for node in layout.roles.iter() { + if node.id == id { + return Some(node.clone()); + } + } + + None +} + +pub fn find_matching_node<'a>( + cand: impl std::iter::Iterator, + pattern: &'a str, +) -> Result { + let mut candidates = vec![]; + for c in cand { + if c.starts_with(pattern) && !candidates.contains(&c) { + candidates.push(c); + } + } + if candidates.len() != 1 { + Err(Error::Message(format!( + "{} nodes match '{}'", + candidates.len(), + pattern, + ))) + } else { + Ok(candidates[0].to_string()) + } +} + +pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { + let has_role_changes = !layout.staged_role_changes.is_empty(); + + // TODO!! Layout parameters + let has_layout_changes = false; + + if has_role_changes || has_layout_changes { + println!(); + println!("==== STAGED ROLE CHANGES ===="); + if has_role_changes { + let mut table = vec!["ID\tTags\tZone\tCapacity".to_string()]; + for change in layout.staged_role_changes.iter() { + match &change.action { + NodeRoleChangeEnum::Update { + tags, + zone, + capacity, + } => { + let tags = tags.join(","); + table.push(format!( + "{:.16}\t{}\t{}\t{}", + change.id, + tags, + zone, + capacity_string(*capacity), + )); + } + NodeRoleChangeEnum::Remove { .. } => { + table.push(format!("{:.16}\tREMOVED", change.id)); + } + } + } + format_table(table); + println!(); + } + //TODO + /* + if has_layout_changes { + println!( + "Zone redundancy: {}", + staging.parameters.get().zone_redundancy + ); + } + */ + true + } else { + false + } +} diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index b51ed67f..51c4d144 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -1,5 +1,3 @@ -pub mod util; - pub mod bucket; pub mod cluster; pub mod key; diff --git a/src/garage/cli_v2/util.rs b/src/garage/cli_v2/util.rs deleted file mode 100644 index 78399b0d..00000000 --- a/src/garage/cli_v2/util.rs +++ /dev/null @@ -1,115 +0,0 @@ -use bytesize::ByteSize; -use format_table::format_table; - -use garage_util::error::Error; - -use garage_api::admin::api::*; - -pub fn capacity_string(v: Option) -> String { - match v { - Some(c) => ByteSize::b(c).to_string_as(false), - None => "gateway".to_string(), - } -} - -pub fn get_staged_or_current_role( - id: &str, - layout: &GetClusterLayoutResponse, -) -> Option { - for node in layout.staged_role_changes.iter() { - if node.id == id { - return match &node.action { - NodeRoleChangeEnum::Remove { .. } => None, - NodeRoleChangeEnum::Update { - zone, - capacity, - tags, - } => Some(NodeRoleResp { - id: id.to_string(), - zone: zone.to_string(), - capacity: *capacity, - tags: tags.clone(), - }), - }; - } - } - - for node in layout.roles.iter() { - if node.id == id { - return Some(node.clone()); - } - } - - None -} - -pub fn find_matching_node<'a>( - cand: impl std::iter::Iterator, - pattern: &'a str, -) -> Result { - let mut candidates = vec![]; - for c in cand { - if c.starts_with(pattern) && !candidates.contains(&c) { - candidates.push(c); - } - } - if candidates.len() != 1 { - Err(Error::Message(format!( - "{} nodes match '{}'", - candidates.len(), - pattern, - ))) - } else { - Ok(candidates[0].to_string()) - } -} - -pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { - let has_role_changes = !layout.staged_role_changes.is_empty(); - - // TODO!! Layout parameters - let has_layout_changes = false; - - if has_role_changes || has_layout_changes { - println!(); - println!("==== STAGED ROLE CHANGES ===="); - if has_role_changes { - let mut table = vec!["ID\tTags\tZone\tCapacity".to_string()]; - for change in layout.staged_role_changes.iter() { - match &change.action { - NodeRoleChangeEnum::Update { - tags, - zone, - capacity, - } => { - let tags = tags.join(","); - table.push(format!( - "{:.16}\t{}\t{}\t{}", - change.id, - tags, - zone, - capacity_string(*capacity), - )); - } - NodeRoleChangeEnum::Remove { .. } => { - table.push(format!("{:.16}\tREMOVED", change.id)); - } - } - } - format_table(table); - println!(); - } - //TODO - /* - if has_layout_changes { - println!( - "Zone redundancy: {}", - staging.parameters.get().zone_redundancy - ); - } - */ - true - } else { - false - } -} From 5a89350b382f9a24d4e81b056f88dc16a5daa080 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 16:40:07 +0100 Subject: [PATCH 039/192] cli_v2: fix garage status --- src/garage/cli_v2/cluster.rs | 92 +++++++++++++++--------------------- src/garage/cli_v2/mod.rs | 1 - 2 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/garage/cli_v2/cluster.rs b/src/garage/cli_v2/cluster.rs index e6ba2428..34a28674 100644 --- a/src/garage/cli_v2/cluster.rs +++ b/src/garage/cli_v2/cluster.rs @@ -12,11 +12,12 @@ impl Cli { pub async fn cmd_status(&self) -> Result<(), Error> { let status = self.api_request(GetClusterStatusRequest).await?; let layout = self.api_request(GetClusterLayoutRequest).await?; - // TODO: layout history println!("==== HEALTHY NODES ===="); + let mut healthy_nodes = vec!["ID\tHostname\tAddress\tTags\tZone\tCapacity\tDataAvail".to_string()]; + for adv in status.nodes.iter().filter(|adv| adv.is_up) { let host = adv.hostname.as_deref().unwrap_or("?"); let addr = match adv.addr { @@ -43,78 +44,43 @@ impl Cli { capacity = capacity_string(cfg.capacity), data_avail = data_avail, )); - } else if adv.draining { - healthy_nodes.push(format!( - "{id:.16}\t{host}\t{addr}\t\t\tdraining metadata...", - id = adv.id, - host = host, - addr = addr, - )); } else { - let new_role = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) { - Some(_) => "pending...", + let status = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) { + Some(NodeRoleChange { + action: NodeRoleChangeEnum::Update { .. }, + .. + }) => "pending...", + _ if adv.draining => "draining metadata..", _ => "NO ROLE ASSIGNED", }; healthy_nodes.push(format!( - "{id:.16}\t{h}\t{addr}\t\t\t{new_role}", + "{id:.16}\t{h}\t{addr}\t\t\t{status}", id = adv.id, h = host, addr = addr, - new_role = new_role, + status = status, )); } } format_table(healthy_nodes); - // Determine which nodes are unhealthy and print that to stdout - // TODO: do we need this, or can it be done in the GetClusterStatus handler? - let status_map = status - .nodes - .iter() - .map(|adv| (&adv.id, adv)) - .collect::>(); - let tf = timeago::Formatter::new(); let mut drain_msg = false; let mut failed_nodes = vec!["ID\tHostname\tTags\tZone\tCapacity\tLast seen".to_string()]; - let mut listed = HashSet::new(); - //for ver in layout.versions.iter().rev() { - for ver in [&layout].iter() { - for cfg in ver.roles.iter() { - let node = &cfg.id; - if listed.contains(node.as_str()) { - continue; - } - listed.insert(node.as_str()); + for adv in status.nodes.iter().filter(|x| !x.is_up) { + let node = &adv.id; - let adv = status_map.get(node); - if adv.map(|x| x.is_up).unwrap_or(false) { - continue; - } + let host = adv.hostname.as_deref().unwrap_or("?"); + let last_seen = adv + .last_seen_secs_ago + .map(|s| tf.convert(Duration::from_secs(s))) + .unwrap_or_else(|| "never seen".into()); - // Node is in a layout version, is not a gateway node, and is not up: - // it is in a failed state, add proper line to the output - let (host, last_seen) = match adv { - Some(adv) => ( - adv.hostname.as_deref().unwrap_or("?"), - adv.last_seen_secs_ago - .map(|s| tf.convert(Duration::from_secs(s))) - .unwrap_or_else(|| "never seen".into()), - ), - None => ("??", "never seen".into()), - }; - /* - let capacity = if ver.version == layout.current().version { - cfg.capacity_string() - } else { - drain_msg = true; - "draining metadata...".to_string() - }; - */ + if let Some(cfg) = &adv.role { let capacity = capacity_string(cfg.capacity); failed_nodes.push(format!( - "{id:?}\t{host}\t[{tags}]\t{zone}\t{capacity}\t{last_seen}", + "{id:.16}\t{host}\t[{tags}]\t{zone}\t{capacity}\t{last_seen}", id = node, host = host, tags = cfg.tags.join(","), @@ -122,6 +88,26 @@ impl Cli { capacity = capacity, last_seen = last_seen, )); + } else { + let status = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) { + Some(NodeRoleChange { + action: NodeRoleChangeEnum::Update { .. }, + .. + }) => "pending...", + _ if adv.draining => { + drain_msg = true; + "draining metadata.." + } + _ => unreachable!(), + }; + + failed_nodes.push(format!( + "{id:.16}\t{host}\t\t\t{status}\t{last_seen}", + id = node, + host = host, + status = status, + last_seen = last_seen, + )); } } diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 51c4d144..692b7c1c 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -3,7 +3,6 @@ pub mod cluster; pub mod key; pub mod layout; -use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; use std::sync::Arc; use std::time::Duration; From bdaf55ab3f866234bd5a7d585758265a88d2906a Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 17:45:54 +0100 Subject: [PATCH 040/192] cli_v2: migrate cleanupincompleteuploads to Admin API admin api: add CleanupIncompleteUploads spec --- doc/api/garage-admin-v2.yml | 40 ++++++++++++++++++++++++++++ doc/drafts/admin-api.md | 22 +++++++++++++++ src/api/admin/api.rs | 14 ++++++++++ src/api/admin/bucket.rs | 21 +++++++++++++++ src/api/admin/router_v2.rs | 1 + src/garage/admin/bucket.rs | 53 ------------------------------------- src/garage/admin/mod.rs | 3 --- src/garage/cli_v2/bucket.rs | 46 +++++++++++++++++++++++++------- 8 files changed, 134 insertions(+), 66 deletions(-) delete mode 100644 src/garage/admin/bucket.rs diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index 725c1d01..f9e3c10c 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -826,6 +826,46 @@ paths: schema: $ref: '#/components/schemas/BucketInfo' + /CleanupIncompleteUploads: + post: + tags: + - Bucket + operationId: "CleanupIncompleteUploads" + summary: "Cleanup incomplete uploads in a bucket" + description: | + Cleanup all incomplete uploads in a bucket that are older than a specified number of seconds + requestBody: + description: | + Bucket id and minimum age of uploads to delete (in seconds) + required: true + content: + application/json: + schema: + type: object + required: [bucketId, olderThanSecs] + properties: + bucketId: + type: string + example: "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b" + olderThanSecs: + type: integer + example: "3600" + responses: + '500': + description: "The server can not handle your request. Check your connectivity with the rest of the cluster." + '400': + description: "The payload is not formatted correctly" + '200': + description: "The bucket was cleaned up successfully" + content: + application/json: + schema: + type: object + properties: + uploadsDeleted: + type: integer + example: 12 + /AllowBucketKey: post: tags: diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index eb327307..029c7ddd 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -702,6 +702,28 @@ Deletes a storage bucket. A bucket cannot be deleted if it is not empty. Warning: this will delete all aliases associated with the bucket! +#### CleanupIncompleteUploads `POST /v2/CleanupIncompleteUploads` + +Cleanup all incomplete uploads in a bucket that are older than a specified number +of seconds. + +Request body format: + +```json +{ + "bucketId": "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b", + "olderThanSecs": 3600 +} +``` + +Response format + +```json +{ + "uploadsDeleted": 12 +} +``` + ### Operations on permissions for keys on buckets diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 99832564..44fc9fca 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -62,6 +62,7 @@ admin_endpoints![ CreateBucket, UpdateBucket, DeleteBucket, + CleanupIncompleteUploads, // Operations on permissions for keys on buckets AllowBucketKey, @@ -497,6 +498,19 @@ pub struct DeleteBucketRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteBucketResponse; +// ---- CleanupIncompleteUploads ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CleanupIncompleteUploadsRequest { + pub bucket_id: String, + pub older_than_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CleanupIncompleteUploadsResponse { + pub uploads_deleted: u64, +} + // ********************************************** // Operations on permissions for keys on buckets // ********************************************** diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 123956ca..7b7c09e7 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; @@ -388,6 +389,26 @@ impl EndpointHandler for UpdateBucketRequest { } } +#[async_trait] +impl EndpointHandler for CleanupIncompleteUploadsRequest { + type Response = CleanupIncompleteUploadsResponse; + + async fn handle(self, garage: &Arc) -> Result { + let duration = Duration::from_secs(self.older_than_secs); + + let bucket_id = parse_bucket_id(&self.bucket_id)?; + + let count = garage + .bucket_helper() + .cleanup_incomplete_uploads(&bucket_id, duration) + .await?; + + Ok(CleanupIncompleteUploadsResponse { + uploads_deleted: count as u64, + }) + } +} + // ---- BUCKET/KEY PERMISSIONS ---- #[async_trait] diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index b36bca34..d1ccceb8 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -52,6 +52,7 @@ impl AdminApiRequest { POST CreateBucket (body), POST DeleteBucket (query::id), POST UpdateBucket (body_field, query::id), + POST CleanupIncompleteUploads (body), // Bucket-key permissions POST AllowBucketKey (body), POST DenyBucketKey (body), diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs deleted file mode 100644 index 26d54084..00000000 --- a/src/garage/admin/bucket.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt::Write; - -use garage_model::helper::error::{Error, OkOrBadRequest}; - -use crate::cli::*; - -use super::*; - -impl AdminRpcHandler { - pub(super) async fn handle_bucket_cmd(&self, cmd: &BucketOperation) -> Result { - match cmd { - BucketOperation::CleanupIncompleteUploads(query) => { - self.handle_bucket_cleanup_incomplete_uploads(query).await - } - _ => unreachable!(), - } - } - - async fn handle_bucket_cleanup_incomplete_uploads( - &self, - query: &CleanupIncompleteUploadsOpt, - ) -> Result { - let mut bucket_ids = vec![]; - for b in query.buckets.iter() { - bucket_ids.push( - self.garage - .bucket_helper() - .admin_get_existing_matching_bucket(b) - .await?, - ); - } - - let duration = parse_duration::parse::parse(&query.older_than) - .ok_or_bad_request("Invalid duration passed for --older-than parameter")?; - - let mut ret = String::new(); - for bucket in bucket_ids { - let count = self - .garage - .bucket_helper() - .cleanup_incomplete_uploads(&bucket, duration) - .await?; - writeln!( - &mut ret, - "Bucket {:?}: {} incomplete uploads aborted", - bucket, count - ) - .unwrap(); - } - - Ok(AdminRpc::Ok(ret)) - } -} diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 70f8ec67..910a875c 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -1,5 +1,4 @@ mod block; -mod bucket; use std::collections::HashMap; use std::fmt::Write; @@ -39,7 +38,6 @@ pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; #[derive(Debug, Serialize, Deserialize)] #[allow(clippy::large_enum_variant)] pub enum AdminRpc { - BucketOperation(BucketOperation), LaunchRepair(RepairOpt), Stats(StatsOpt), Worker(WorkerOperation), @@ -532,7 +530,6 @@ impl EndpointHandler for AdminRpcHandler { _from: NodeID, ) -> Result { match message { - AdminRpc::BucketOperation(bo) => self.handle_bucket_cmd(bo).await, AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, AdminRpc::Stats(opt) => self.handle_stats(opt.clone()).await, AdminRpc::Worker(wo) => self.handle_worker_cmd(wo).await, diff --git a/src/garage/cli_v2/bucket.rs b/src/garage/cli_v2/bucket.rs index ee3b6800..c25c2c3e 100644 --- a/src/garage/cli_v2/bucket.rs +++ b/src/garage/cli_v2/bucket.rs @@ -5,7 +5,6 @@ use garage_util::error::*; use garage_api_admin::api::*; -use crate::cli as cli_v1; use crate::cli::structs::*; use crate::cli_v2::*; @@ -22,15 +21,9 @@ impl Cli { BucketOperation::Deny(query) => self.cmd_bucket_deny(query).await, BucketOperation::Website(query) => self.cmd_bucket_website(query).await, BucketOperation::SetQuotas(query) => self.cmd_bucket_set_quotas(query).await, - - // TODO - x => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::BucketOperation(x), - ) - .await - .ok_or_message("old error"), + BucketOperation::CleanupIncompleteUploads(query) => { + self.cmd_cleanup_incomplete_uploads(query).await + } } } @@ -520,4 +513,37 @@ impl Cli { Ok(()) } + + pub async fn cmd_cleanup_incomplete_uploads( + &self, + opt: CleanupIncompleteUploadsOpt, + ) -> Result<(), Error> { + let older_than = parse_duration::parse::parse(&opt.older_than) + .ok_or_message("Invalid duration passed for --older-than parameter")?; + + for b in opt.buckets.iter() { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(b.clone()), + }) + .await?; + + let res = self + .api_request(CleanupIncompleteUploadsRequest { + bucket_id: bucket.id.clone(), + older_than_secs: older_than.as_secs(), + }) + .await?; + + if res.uploads_deleted > 0 { + println!("{:.16}: {} uploads deleted", bucket.id, res.uploads_deleted); + } else { + println!("{:.16}: no uploads deleted", bucket.id); + } + } + + Ok(()) + } } From 89ff9f5576f91dc127ba3cc1fae96543e27b9468 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 30 Jan 2025 19:08:48 +0100 Subject: [PATCH 041/192] admin api: base infrastructure for local endpoints admin api: rename EndpointHandler into RequestHandler to avoid confusion with RPC wip: infrastructure for local api calls admin api: fix things admin api: first local endpoint to work with new scheme admin api: implement SetWorkerVariable --- src/api/admin/api.rs | 41 ++++++++++- src/api/admin/api_server.rs | 129 ++++++++++++++++++++++++++------ src/api/admin/bucket.rs | 82 +++++++++++++++------ src/api/admin/cluster.rs | 58 +++++++++++---- src/api/admin/key.rs | 46 ++++++++---- src/api/admin/lib.rs | 12 ++- src/api/admin/macros.rs | 142 +++++++++++++++++++++++++++++++++++- src/api/admin/router_v2.rs | 3 + src/api/admin/special.rs | 26 +++++-- src/api/admin/worker.rs | 50 +++++++++++++ src/garage/admin/mod.rs | 128 +------------------------------- src/garage/cli/cmd.rs | 3 - src/garage/cli/util.rs | 8 -- src/garage/cli_v2/mod.rs | 30 ++++---- src/garage/cli_v2/worker.rs | 89 ++++++++++++++++++++++ src/garage/main.rs | 4 + src/garage/server.rs | 4 +- 17 files changed, 619 insertions(+), 236 deletions(-) create mode 100644 src/api/admin/worker.rs create mode 100644 src/garage/cli_v2/worker.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 44fc9fca..89ddb286 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::convert::TryFrom; use std::net::SocketAddr; use std::sync::Arc; @@ -6,13 +7,17 @@ use async_trait::async_trait; use paste::paste; use serde::{Deserialize, Serialize}; +use garage_rpc::*; + use garage_model::garage::Garage; +use garage_api_common::common_error::CommonErrorDerivative; use garage_api_common::helpers::is_default; +use crate::api_server::{AdminRpc, AdminRpcResponse}; use crate::error::Error; use crate::macros::*; -use crate::EndpointHandler; +use crate::{Admin, RequestHandler}; // This generates the following: // @@ -71,8 +76,14 @@ admin_endpoints![ // Operations on bucket aliases AddBucketAlias, RemoveBucketAlias, + + // Worker operations + GetWorkerVariable, + SetWorkerVariable, ]; +local_admin_endpoints![GetWorkerVariable, SetWorkerVariable,]; + // ********************************************** // Special endpoints // @@ -580,3 +591,31 @@ pub struct RemoveBucketAliasRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); + +// ********************************************** +// Worker operations +// ********************************************** + +// ---- GetWorkerVariable ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalGetWorkerVariableRequest { + pub variable: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalGetWorkerVariableResponse(pub HashMap); + +// ---- SetWorkerVariable ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSetWorkerVariableRequest { + pub variable: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSetWorkerVariableResponse { + pub variable: String, + pub value: String, +} diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index be29e617..e865d199 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION}; use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; use tokio::sync::watch; use opentelemetry::trace::SpanRef; @@ -16,6 +17,8 @@ use opentelemetry_prometheus::PrometheusExporter; use prometheus::{Encoder, TextEncoder}; use garage_model::garage::Garage; +use garage_rpc::{Endpoint as RpcEndpoint, *}; +use garage_util::background::BackgroundRunner; use garage_util::error::Error as GarageError; use garage_util::socket_address::UnixOrTCPSocketAddress; @@ -27,7 +30,70 @@ use crate::error::*; use crate::router_v0; use crate::router_v1; use crate::Authorization; -use crate::EndpointHandler; +use crate::RequestHandler; + +// ---- FOR RPC ---- + +pub const ADMIN_RPC_PATH: &str = "garage_api/admin/rpc.rs/Rpc"; + +#[derive(Debug, Serialize, Deserialize)] +pub enum AdminRpc { + Proxy(AdminApiRequest), + Internal(LocalAdminApiRequest), +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum AdminRpcResponse { + ProxyApiOkResponse(TaggedAdminApiResponse), + InternalApiOkResponse(LocalAdminApiResponse), + ApiErrorResponse { + http_code: u16, + error_code: String, + message: String, + }, +} + +impl Rpc for AdminRpc { + type Response = Result; +} + +#[async_trait] +impl EndpointHandler for AdminApiServer { + async fn handle( + self: &Arc, + message: &AdminRpc, + _from: NodeID, + ) -> Result { + match message { + AdminRpc::Proxy(req) => { + info!("Proxied admin API request: {}", req.name()); + let res = req.clone().handle(&self.garage, &self).await; + match res { + Ok(res) => Ok(AdminRpcResponse::ProxyApiOkResponse(res.tagged())), + Err(e) => Ok(AdminRpcResponse::ApiErrorResponse { + http_code: e.http_status_code().as_u16(), + error_code: e.code().to_string(), + message: e.to_string(), + }), + } + } + AdminRpc::Internal(req) => { + info!("Internal admin API request: {}", req.name()); + let res = req.clone().handle(&self.garage, &self).await; + match res { + Ok(res) => Ok(AdminRpcResponse::InternalApiOkResponse(res)), + Err(e) => Ok(AdminRpcResponse::ApiErrorResponse { + http_code: e.http_status_code().as_u16(), + error_code: e.code().to_string(), + message: e.to_string(), + }), + } + } + } + } +} + +// ---- FOR HTTP ---- pub type ResBody = BoxBody; @@ -37,37 +103,48 @@ pub struct AdminApiServer { exporter: PrometheusExporter, metrics_token: Option, admin_token: Option, + pub(crate) background: Arc, + pub(crate) endpoint: Arc>, } -pub enum Endpoint { +pub enum HttpEndpoint { Old(router_v1::Endpoint), New(String), } +struct ArcAdminApiServer(Arc); + impl AdminApiServer { pub fn new( garage: Arc, + background: Arc, #[cfg(feature = "metrics")] exporter: PrometheusExporter, - ) -> Self { + ) -> Arc { let cfg = &garage.config.admin; let metrics_token = cfg.metrics_token.as_deref().map(hash_bearer_token); let admin_token = cfg.admin_token.as_deref().map(hash_bearer_token); - Self { + + let endpoint = garage.system.netapp.endpoint(ADMIN_RPC_PATH.into()); + let admin = Arc::new(Self { garage, #[cfg(feature = "metrics")] exporter, metrics_token, admin_token, - } + background, + endpoint, + }); + admin.endpoint.set_handler(admin.clone()); + admin } pub async fn run( - self, + self: Arc, bind_addr: UnixOrTCPSocketAddress, must_exit: watch::Receiver, ) -> Result<(), GarageError> { let region = self.garage.config.s3_api.s3_region.clone(); - ApiServer::new(region, self) + ApiServer::new(region, ArcAdminApiServer(self)) .run_server(bind_addr, Some(0o220), must_exit) .await } @@ -102,36 +179,46 @@ impl AdminApiServer { } #[async_trait] -impl ApiHandler for AdminApiServer { +impl ApiHandler for ArcAdminApiServer { const API_NAME: &'static str = "admin"; const API_NAME_DISPLAY: &'static str = "Admin"; - type Endpoint = Endpoint; + type Endpoint = HttpEndpoint; type Error = Error; - fn parse_endpoint(&self, req: &Request) -> Result { + fn parse_endpoint(&self, req: &Request) -> Result { if req.uri().path().starts_with("/v0/") { let endpoint_v0 = router_v0::Endpoint::from_request(req)?; let endpoint_v1 = router_v1::Endpoint::from_v0(endpoint_v0)?; - Ok(Endpoint::Old(endpoint_v1)) + Ok(HttpEndpoint::Old(endpoint_v1)) } else if req.uri().path().starts_with("/v1/") { let endpoint_v1 = router_v1::Endpoint::from_request(req)?; - Ok(Endpoint::Old(endpoint_v1)) + Ok(HttpEndpoint::Old(endpoint_v1)) } else { - Ok(Endpoint::New(req.uri().path().to_string())) + Ok(HttpEndpoint::New(req.uri().path().to_string())) } } async fn handle( &self, req: Request, - endpoint: Endpoint, + endpoint: HttpEndpoint, + ) -> Result, Error> { + self.0.handle_http_api(req, endpoint).await + } +} + +impl AdminApiServer { + async fn handle_http_api( + &self, + req: Request, + endpoint: HttpEndpoint, ) -> Result, Error> { let auth_header = req.headers().get(AUTHORIZATION).cloned(); let request = match endpoint { - Endpoint::Old(endpoint_v1) => AdminApiRequest::from_v1(endpoint_v1, req).await?, - Endpoint::New(_) => AdminApiRequest::from_request(req).await?, + HttpEndpoint::Old(endpoint_v1) => AdminApiRequest::from_v1(endpoint_v1, req).await?, + HttpEndpoint::New(_) => AdminApiRequest::from_request(req).await?, }; let required_auth_hash = @@ -156,12 +243,12 @@ impl ApiHandler for AdminApiServer { } match request { - AdminApiRequest::Options(req) => req.handle(&self.garage).await, - AdminApiRequest::CheckDomain(req) => req.handle(&self.garage).await, - AdminApiRequest::Health(req) => req.handle(&self.garage).await, + AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await, + AdminApiRequest::CheckDomain(req) => req.handle(&self.garage, &self).await, + AdminApiRequest::Health(req) => req.handle(&self.garage, &self).await, AdminApiRequest::Metrics(_req) => self.handle_metrics(), req => { - let res = req.handle(&self.garage).await?; + let res = req.handle(&self.garage, &self).await?; let mut res = json_ok_response(&res)?; res.headers_mut() .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); @@ -171,7 +258,7 @@ impl ApiHandler for AdminApiServer { } } -impl ApiEndpoint for Endpoint { +impl ApiEndpoint for HttpEndpoint { fn name(&self) -> Cow<'static, str> { match self { Self::Old(endpoint_v1) => Cow::Borrowed(endpoint_v1.name()), diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 7b7c09e7..73e63df0 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -21,13 +21,17 @@ use garage_api_common::common_error::CommonError; use crate::api::*; use crate::error::*; -use crate::EndpointHandler; +use crate::{Admin, RequestHandler}; #[async_trait] -impl EndpointHandler for ListBucketsRequest { +impl RequestHandler for ListBucketsRequest { type Response = ListBucketsResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let buckets = garage .bucket_table .get_range( @@ -71,10 +75,14 @@ impl EndpointHandler for ListBucketsRequest { } #[async_trait] -impl EndpointHandler for GetBucketInfoRequest { +impl RequestHandler for GetBucketInfoRequest { type Response = GetBucketInfoResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let bucket_id = match (self.id, self.global_alias, self.search) { (Some(id), None, None) => parse_bucket_id(&id)?, (None, Some(ga), None) => garage @@ -223,10 +231,14 @@ async fn bucket_info_results( } #[async_trait] -impl EndpointHandler for CreateBucketRequest { +impl RequestHandler for CreateBucketRequest { type Response = CreateBucketResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let helper = garage.locked_helper().await; if let Some(ga) = &self.global_alias { @@ -294,10 +306,14 @@ impl EndpointHandler for CreateBucketRequest { } #[async_trait] -impl EndpointHandler for DeleteBucketRequest { +impl RequestHandler for DeleteBucketRequest { type Response = DeleteBucketResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let helper = garage.locked_helper().await; let bucket_id = parse_bucket_id(&self.id)?; @@ -343,10 +359,14 @@ impl EndpointHandler for DeleteBucketRequest { } #[async_trait] -impl EndpointHandler for UpdateBucketRequest { +impl RequestHandler for UpdateBucketRequest { type Response = UpdateBucketResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let bucket_id = parse_bucket_id(&self.id)?; let mut bucket = garage @@ -390,10 +410,14 @@ impl EndpointHandler for UpdateBucketRequest { } #[async_trait] -impl EndpointHandler for CleanupIncompleteUploadsRequest { +impl RequestHandler for CleanupIncompleteUploadsRequest { type Response = CleanupIncompleteUploadsResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let duration = Duration::from_secs(self.older_than_secs); let bucket_id = parse_bucket_id(&self.bucket_id)?; @@ -412,20 +436,28 @@ impl EndpointHandler for CleanupIncompleteUploadsRequest { // ---- BUCKET/KEY PERMISSIONS ---- #[async_trait] -impl EndpointHandler for AllowBucketKeyRequest { +impl RequestHandler for AllowBucketKeyRequest { type Response = AllowBucketKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let res = handle_bucket_change_key_perm(garage, self.0, true).await?; Ok(AllowBucketKeyResponse(res)) } } #[async_trait] -impl EndpointHandler for DenyBucketKeyRequest { +impl RequestHandler for DenyBucketKeyRequest { type Response = DenyBucketKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let res = handle_bucket_change_key_perm(garage, self.0, false).await?; Ok(DenyBucketKeyResponse(res)) } @@ -471,10 +503,14 @@ pub async fn handle_bucket_change_key_perm( // ---- BUCKET ALIASES ---- #[async_trait] -impl EndpointHandler for AddBucketAliasRequest { +impl RequestHandler for AddBucketAliasRequest { type Response = AddBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; @@ -502,10 +538,14 @@ impl EndpointHandler for AddBucketAliasRequest { } #[async_trait] -impl EndpointHandler for RemoveBucketAliasRequest { +impl RequestHandler for RemoveBucketAliasRequest { type Response = RemoveBucketAliasResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let bucket_id = parse_bucket_id(&self.bucket_id)?; let helper = garage.locked_helper().await; diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index dc16bd50..6a7a3d69 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -12,13 +12,17 @@ use garage_model::garage::Garage; use crate::api::*; use crate::error::*; -use crate::EndpointHandler; +use crate::{Admin, RequestHandler}; #[async_trait] -impl EndpointHandler for GetClusterStatusRequest { +impl RequestHandler for GetClusterStatusRequest { type Response = GetClusterStatusResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let layout = garage.system.cluster_layout(); let mut nodes = garage .system @@ -117,10 +121,14 @@ impl EndpointHandler for GetClusterStatusRequest { } #[async_trait] -impl EndpointHandler for GetClusterHealthRequest { +impl RequestHandler for GetClusterHealthRequest { type Response = GetClusterHealthResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { use garage_rpc::system::ClusterHealthStatus; let health = garage.system.health(); let health = GetClusterHealthResponse { @@ -143,10 +151,14 @@ impl EndpointHandler for GetClusterHealthRequest { } #[async_trait] -impl EndpointHandler for ConnectClusterNodesRequest { +impl RequestHandler for ConnectClusterNodesRequest { type Response = ConnectClusterNodesResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let res = futures::future::join_all(self.0.iter().map(|node| garage.system.connect(node))) .await .into_iter() @@ -166,10 +178,14 @@ impl EndpointHandler for ConnectClusterNodesRequest { } #[async_trait] -impl EndpointHandler for GetClusterLayoutRequest { +impl RequestHandler for GetClusterLayoutRequest { type Response = GetClusterLayoutResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { Ok(format_cluster_layout( garage.system.cluster_layout().inner(), )) @@ -226,10 +242,14 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp // ---- update functions ---- #[async_trait] -impl EndpointHandler for UpdateClusterLayoutRequest { +impl RequestHandler for UpdateClusterLayoutRequest { type Response = UpdateClusterLayoutResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let mut layout = garage.system.cluster_layout().inner().clone(); let mut roles = layout.current().roles.clone(); @@ -272,10 +292,14 @@ impl EndpointHandler for UpdateClusterLayoutRequest { } #[async_trait] -impl EndpointHandler for ApplyClusterLayoutRequest { +impl RequestHandler for ApplyClusterLayoutRequest { type Response = ApplyClusterLayoutResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let layout = garage.system.cluster_layout().inner().clone(); let (layout, msg) = layout.apply_staged_changes(Some(self.version))?; @@ -293,10 +317,14 @@ impl EndpointHandler for ApplyClusterLayoutRequest { } #[async_trait] -impl EndpointHandler for RevertClusterLayoutRequest { +impl RequestHandler for RevertClusterLayoutRequest { type Response = RevertClusterLayoutResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let layout = garage.system.cluster_layout().inner().clone(); let layout = layout.revert_staged_changes()?; garage diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 5b7de075..440a8322 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -10,13 +10,13 @@ use garage_model::key_table::*; use crate::api::*; use crate::error::*; -use crate::EndpointHandler; +use crate::{Admin, RequestHandler}; #[async_trait] -impl EndpointHandler for ListKeysRequest { +impl RequestHandler for ListKeysRequest { type Response = ListKeysResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc, _admin: &Admin) -> Result { let res = garage .key_table .get_range( @@ -39,10 +39,14 @@ impl EndpointHandler for ListKeysRequest { } #[async_trait] -impl EndpointHandler for GetKeyInfoRequest { +impl RequestHandler for GetKeyInfoRequest { type Response = GetKeyInfoResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let key = match (self.id, self.search) { (Some(id), None) => garage.key_helper().get_existing_key(&id).await?, (None, Some(search)) => { @@ -63,10 +67,14 @@ impl EndpointHandler for GetKeyInfoRequest { } #[async_trait] -impl EndpointHandler for CreateKeyRequest { +impl RequestHandler for CreateKeyRequest { type Response = CreateKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let key = Key::new(self.name.as_deref().unwrap_or("Unnamed key")); garage.key_table.insert(&key).await?; @@ -77,10 +85,14 @@ impl EndpointHandler for CreateKeyRequest { } #[async_trait] -impl EndpointHandler for ImportKeyRequest { +impl RequestHandler for ImportKeyRequest { type Response = ImportKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let prev_key = garage.key_table.get(&EmptyKey, &self.access_key_id).await?; if prev_key.is_some() { return Err(Error::KeyAlreadyExists(self.access_key_id.to_string())); @@ -101,10 +113,14 @@ impl EndpointHandler for ImportKeyRequest { } #[async_trait] -impl EndpointHandler for UpdateKeyRequest { +impl RequestHandler for UpdateKeyRequest { type Response = UpdateKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let mut key = garage.key_helper().get_existing_key(&self.id).await?; let key_state = key.state.as_option_mut().unwrap(); @@ -132,10 +148,14 @@ impl EndpointHandler for UpdateKeyRequest { } #[async_trait] -impl EndpointHandler for DeleteKeyRequest { +impl RequestHandler for DeleteKeyRequest { type Response = DeleteKeyResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { let helper = garage.locked_helper().await; let mut key = helper.key().get_existing_key(&self.id).await?; diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index 31b3874d..4ad10532 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -15,12 +15,16 @@ mod cluster; mod key; mod special; +mod worker; + use std::sync::Arc; use async_trait::async_trait; use garage_model::garage::Garage; +pub use api_server::AdminApiServer as Admin; + pub enum Authorization { None, MetricsToken, @@ -28,8 +32,12 @@ pub enum Authorization { } #[async_trait] -pub trait EndpointHandler { +pub trait RequestHandler { type Response; - async fn handle(self, garage: &Arc) -> Result; + async fn handle( + self, + garage: &Arc, + admin: &Admin, + ) -> Result; } diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index 9521616e..bf7eede9 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -71,10 +71,10 @@ macro_rules! admin_endpoints { )* #[async_trait] - impl EndpointHandler for AdminApiRequest { + impl RequestHandler for AdminApiRequest { type Response = AdminApiResponse; - async fn handle(self, garage: &Arc) -> Result { + async fn handle(self, garage: &Arc, admin: &Admin) -> Result { Ok(match self { $( AdminApiRequest::$special_endpoint(_) => panic!( @@ -82,7 +82,142 @@ macro_rules! admin_endpoints { ), )* $( - AdminApiRequest::$endpoint(req) => AdminApiResponse::$endpoint(req.handle(garage).await?), + AdminApiRequest::$endpoint(req) => AdminApiResponse::$endpoint(req.handle(garage, admin).await?), + )* + }) + } + } + } + }; +} + +macro_rules! local_admin_endpoints { + [ + $($endpoint:ident,)* + ] => { + paste! { + #[derive(Debug, Clone, Serialize, Deserialize)] + pub enum LocalAdminApiRequest { + $( + $endpoint( [] ), + )* + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub enum LocalAdminApiResponse { + $( + $endpoint( [] ), + )* + } + + $( + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct [< $endpoint Request >] { + pub node: String, + pub body: [< Local $endpoint Request >], + } + + pub type [< $endpoint RequestBody >] = [< Local $endpoint Request >]; + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct [< $endpoint Response >] { + pub success: HashMap] >, + pub error: HashMap, + } + + impl From< [< Local $endpoint Request >] > for LocalAdminApiRequest { + fn from(req: [< Local $endpoint Request >]) -> LocalAdminApiRequest { + LocalAdminApiRequest::$endpoint(req) + } + } + + impl TryFrom for [< Local $endpoint Response >] { + type Error = LocalAdminApiResponse; + fn try_from(resp: LocalAdminApiResponse) -> Result< [< Local $endpoint Response >], LocalAdminApiResponse> { + match resp { + LocalAdminApiResponse::$endpoint(v) => Ok(v), + x => Err(x), + } + } + } + + #[async_trait] + impl RequestHandler for [< $endpoint Request >] { + type Response = [< $endpoint Response >]; + + async fn handle(self, garage: &Arc, admin: &Admin) -> Result { + let to = match self.node.as_str() { + "*" => garage.system.cluster_layout().all_nodes().to_vec(), + id => { + let nodes = garage.system.cluster_layout().all_nodes() + .iter() + .filter(|x| hex::encode(x).starts_with(id)) + .cloned() + .collect::>(); + if nodes.len() != 1 { + return Err(Error::bad_request(format!("Zero or multiple nodes matching {}: {:?}", id, nodes))); + } + nodes + } + }; + + let resps = garage.system.rpc_helper().call_many(&admin.endpoint, + &to, + AdminRpc::Internal(self.body.into()), + RequestStrategy::with_priority(PRIO_NORMAL), + ).await?; + + let mut ret = [< $endpoint Response >] { + success: HashMap::new(), + error: HashMap::new(), + }; + for (node, resp) in resps { + match resp { + Ok(AdminRpcResponse::InternalApiOkResponse(r)) => { + match [< Local $endpoint Response >]::try_from(r) { + Ok(r) => { + ret.success.insert(hex::encode(node), r); + } + Err(_) => { + ret.error.insert(hex::encode(node), "returned invalid value".to_string()); + } + } + } + Ok(AdminRpcResponse::ApiErrorResponse{error_code, http_code, message}) => { + ret.error.insert(hex::encode(node), format!("{} ({}): {}", error_code, http_code, message)); + } + Ok(_) => { + ret.error.insert(hex::encode(node), "returned invalid value".to_string()); + } + Err(e) => { + ret.error.insert(hex::encode(node), e.to_string()); + } + } + } + + Ok(ret) + } + } + )* + + impl LocalAdminApiRequest { + pub fn name(&self) -> &'static str { + match self { + $( + Self::$endpoint(_) => stringify!($endpoint), + )* + } + } + } + + #[async_trait] + impl RequestHandler for LocalAdminApiRequest { + type Response = LocalAdminApiResponse; + + async fn handle(self, garage: &Arc, admin: &Admin) -> Result { + Ok(match self { + $( + LocalAdminApiRequest::$endpoint(req) => LocalAdminApiResponse::$endpoint(req.handle(garage, admin).await?), )* }) } @@ -92,3 +227,4 @@ macro_rules! admin_endpoints { } pub(crate) use admin_endpoints; +pub(crate) use local_admin_endpoints; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index d1ccceb8..e0ce5b93 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -59,6 +59,8 @@ impl AdminApiRequest { // Bucket aliases POST AddBucketAlias (body), POST RemoveBucketAlias (body), + // Worker APIs + POST GetWorkerVariable (body_field, query::node), ]); if let Some(message) = query.nonempty_message() { @@ -240,6 +242,7 @@ impl AdminApiRequest { generateQueryParameters! { keywords: [], fields: [ + "node" => node, "domain" => domain, "format" => format, "id" => id, diff --git a/src/api/admin/special.rs b/src/api/admin/special.rs index 0b26fe32..4717238d 100644 --- a/src/api/admin/special.rs +++ b/src/api/admin/special.rs @@ -15,13 +15,17 @@ use garage_api_common::helpers::*; use crate::api::{CheckDomainRequest, HealthRequest, OptionsRequest}; use crate::api_server::ResBody; use crate::error::*; -use crate::EndpointHandler; +use crate::{Admin, RequestHandler}; #[async_trait] -impl EndpointHandler for OptionsRequest { +impl RequestHandler for OptionsRequest { type Response = Response; - async fn handle(self, _garage: &Arc) -> Result, Error> { + async fn handle( + self, + _garage: &Arc, + _admin: &Admin, + ) -> Result, Error> { Ok(Response::builder() .status(StatusCode::OK) .header(ALLOW, "OPTIONS,GET,POST") @@ -33,10 +37,14 @@ impl EndpointHandler for OptionsRequest { } #[async_trait] -impl EndpointHandler for CheckDomainRequest { +impl RequestHandler for CheckDomainRequest { type Response = Response; - async fn handle(self, garage: &Arc) -> Result, Error> { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result, Error> { if check_domain(garage, &self.domain).await? { Ok(Response::builder() .status(StatusCode::OK) @@ -103,10 +111,14 @@ async fn check_domain(garage: &Arc, domain: &str) -> Result } #[async_trait] -impl EndpointHandler for HealthRequest { +impl RequestHandler for HealthRequest { type Response = Response; - async fn handle(self, garage: &Arc) -> Result, Error> { + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result, Error> { let health = garage.system.health(); let (status, status_str) = match health.status { diff --git a/src/api/admin/worker.rs b/src/api/admin/worker.rs new file mode 100644 index 00000000..78508175 --- /dev/null +++ b/src/api/admin/worker.rs @@ -0,0 +1,50 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; + +use garage_model::garage::Garage; + +use crate::api::*; +use crate::error::Error; +use crate::{Admin, RequestHandler}; + +#[async_trait] +impl RequestHandler for LocalGetWorkerVariableRequest { + type Response = LocalGetWorkerVariableResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut res = HashMap::new(); + if let Some(k) = self.variable { + res.insert(k.clone(), garage.bg_vars.get(&k)?); + } else { + let vars = garage.bg_vars.get_all(); + for (k, v) in vars.iter() { + res.insert(k.to_string(), v.to_string()); + } + } + Ok(LocalGetWorkerVariableResponse(res)) + } +} + +#[async_trait] +impl RequestHandler for LocalSetWorkerVariableRequest { + type Response = LocalSetWorkerVariableResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + garage.bg_vars.set(&self.variable, &self.value)?; + + Ok(LocalSetWorkerVariableResponse { + variable: self.variable, + value: self.value, + }) + } +} diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 910a875c..f493d0c5 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -27,7 +27,7 @@ use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::Version; use garage_api_admin::api::{AdminApiRequest, TaggedAdminApiResponse}; -use garage_api_admin::EndpointHandler as AdminApiEndpoint; +use garage_api_admin::RequestHandler as AdminApiEndpoint; use garage_api_common::generic_server::ApiError; use crate::cli::*; @@ -50,7 +50,6 @@ pub enum AdminRpc { HashMap, WorkerListOpt, ), - WorkerVars(Vec<(Uuid, String, String)>), WorkerInfo(usize, garage_util::background::WorkerInfo), BlockErrorList(Vec), BlockInfo { @@ -59,15 +58,6 @@ pub enum AdminRpc { versions: Vec>, uploads: Vec, }, - - // Proxying HTTP Admin API endpoints - ApiRequest(AdminApiRequest), - ApiOkResponse(TaggedAdminApiResponse), - ApiErrorResponse { - http_code: u16, - error_code: String, - message: String, - }, } impl Rpc for AdminRpc { @@ -367,101 +357,7 @@ impl AdminRpcHandler { .clone(); Ok(AdminRpc::WorkerInfo(*tid, info)) } - WorkerOperation::Get { - all_nodes, - variable, - } => self.handle_get_var(*all_nodes, variable).await, - WorkerOperation::Set { - all_nodes, - variable, - value, - } => self.handle_set_var(*all_nodes, variable, value).await, - } - } - - async fn handle_get_var( - &self, - all_nodes: bool, - variable: &Option, - ) -> Result { - if all_nodes { - let mut ret = vec![]; - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in all_nodes.iter() { - let node = (*node).into(); - match self - .endpoint - .call( - &node, - AdminRpc::Worker(WorkerOperation::Get { - all_nodes: false, - variable: variable.clone(), - }), - PRIO_NORMAL, - ) - .await?? - { - AdminRpc::WorkerVars(v) => ret.extend(v), - m => return Err(GarageError::unexpected_rpc_message(m).into()), - } - } - Ok(AdminRpc::WorkerVars(ret)) - } else { - #[allow(clippy::collapsible_else_if)] - if let Some(v) = variable { - Ok(AdminRpc::WorkerVars(vec![( - self.garage.system.id, - v.clone(), - self.garage.bg_vars.get(v)?, - )])) - } else { - let mut vars = self.garage.bg_vars.get_all(); - vars.sort(); - Ok(AdminRpc::WorkerVars( - vars.into_iter() - .map(|(k, v)| (self.garage.system.id, k.to_string(), v)) - .collect(), - )) - } - } - } - - async fn handle_set_var( - &self, - all_nodes: bool, - variable: &str, - value: &str, - ) -> Result { - if all_nodes { - let mut ret = vec![]; - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in all_nodes.iter() { - let node = (*node).into(); - match self - .endpoint - .call( - &node, - AdminRpc::Worker(WorkerOperation::Set { - all_nodes: false, - variable: variable.to_string(), - value: value.to_string(), - }), - PRIO_NORMAL, - ) - .await?? - { - AdminRpc::WorkerVars(v) => ret.extend(v), - m => return Err(GarageError::unexpected_rpc_message(m).into()), - } - } - Ok(AdminRpc::WorkerVars(ret)) - } else { - self.garage.bg_vars.set(variable, value)?; - Ok(AdminRpc::WorkerVars(vec![( - self.garage.system.id, - variable.to_string(), - value.to_string(), - )])) + _ => unreachable!(), } } @@ -501,25 +397,6 @@ impl AdminRpcHandler { } } } - - // ================== PROXYING ADMIN API REQUESTS =================== - - async fn handle_api_request( - self: &Arc, - req: &AdminApiRequest, - ) -> Result { - let req = req.clone(); - info!("Proxied admin API request: {}", req.name()); - let res = req.handle(&self.garage).await; - match res { - Ok(res) => Ok(AdminRpc::ApiOkResponse(res.tagged())), - Err(e) => Ok(AdminRpc::ApiErrorResponse { - http_code: e.http_status_code().as_u16(), - error_code: e.code().to_string(), - message: e.to_string(), - }), - } - } } #[async_trait] @@ -535,7 +412,6 @@ impl EndpointHandler for AdminRpcHandler { AdminRpc::Worker(wo) => self.handle_worker_cmd(wo).await, AdminRpc::BlockOperation(bo) => self.handle_block_cmd(bo).await, AdminRpc::MetaOperation(mo) => self.handle_meta_cmd(mo).await, - AdminRpc::ApiRequest(r) => self.handle_api_request(r).await, m => Err(GarageError::unexpected_rpc_message(m).into()), } } diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index a6540c65..6f1b0681 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -20,9 +20,6 @@ pub async fn cmd_admin( AdminRpc::WorkerList(wi, wlo) => { print_worker_list(wi, wlo); } - AdminRpc::WorkerVars(wv) => { - print_worker_vars(wv); - } AdminRpc::WorkerInfo(tid, wi) => { print_worker_info(tid, wi); } diff --git a/src/garage/cli/util.rs b/src/garage/cli/util.rs index a3a1480e..8261fb3e 100644 --- a/src/garage/cli/util.rs +++ b/src/garage/cli/util.rs @@ -126,14 +126,6 @@ pub fn print_worker_info(tid: usize, info: WorkerInfo) { format_table(table); } -pub fn print_worker_vars(wv: Vec<(Uuid, String, String)>) { - let table = wv - .into_iter() - .map(|(n, k, v)| format!("{:?}\t{}\t{}", n, k, v)) - .collect::>(); - format_table(table); -} - pub fn print_block_error_list(el: Vec) { let now = now_msec(); let tf = timeago::Formatter::new(); diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 6cc13b2d..b9bf05fe 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -3,6 +3,8 @@ pub mod cluster; pub mod key; pub mod layout; +pub mod worker; + use std::convert::TryFrom; use std::sync::Arc; use std::time::Duration; @@ -13,7 +15,8 @@ use garage_rpc::system::*; use garage_rpc::*; use garage_api_admin::api::*; -use garage_api_admin::EndpointHandler as AdminApiEndpoint; +use garage_api_admin::api_server::{AdminRpc as ProxyRpc, AdminRpcResponse as ProxyRpcResponse}; +use garage_api_admin::RequestHandler as AdminApiEndpoint; use crate::admin::*; use crate::cli as cli_v1; @@ -23,6 +26,7 @@ use crate::cli::Command; pub struct Cli { pub system_rpc_endpoint: Arc>, pub admin_rpc_endpoint: Arc>, + pub proxy_rpc_endpoint: Arc>, pub rpc_host: NodeID, } @@ -36,6 +40,7 @@ impl Cli { Command::Layout(layout_opt) => self.layout_command_dispatch(layout_opt).await, Command::Bucket(bo) => self.cmd_bucket(bo).await, Command::Key(ko) => self.cmd_key(ko).await, + Command::Worker(wo) => self.cmd_worker(wo).await, // TODO Command::Repair(ro) => cli_v1::cmd_admin( @@ -50,13 +55,6 @@ impl Cli { .await .ok_or_message("cli_v1") } - Command::Worker(wo) => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::Worker(wo), - ) - .await - .ok_or_message("cli_v1"), Command::Block(bo) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, self.rpc_host, @@ -85,14 +83,16 @@ impl Cli { let req = AdminApiRequest::from(req); let req_name = req.name(); match self - .admin_rpc_endpoint - .call(&self.rpc_host, AdminRpc::ApiRequest(req), PRIO_NORMAL) - .await? - .ok_or_message("rpc")? + .proxy_rpc_endpoint + .call(&self.rpc_host, ProxyRpc::Proxy(req), PRIO_NORMAL) + .await?? { - AdminRpc::ApiOkResponse(resp) => ::Response::try_from(resp) - .map_err(|_| Error::Message(format!("{} returned unexpected response", req_name))), - AdminRpc::ApiErrorResponse { + ProxyRpcResponse::ProxyApiOkResponse(resp) => { + ::Response::try_from(resp).map_err(|_| { + Error::Message(format!("{} returned unexpected response", req_name)) + }) + } + ProxyRpcResponse::ApiErrorResponse { http_code, error_code, message, diff --git a/src/garage/cli_v2/worker.rs b/src/garage/cli_v2/worker.rs new file mode 100644 index 00000000..0dfe3e96 --- /dev/null +++ b/src/garage/cli_v2/worker.rs @@ -0,0 +1,89 @@ +//use bytesize::ByteSize; +use format_table::format_table; + +use garage_util::error::*; + +use garage_api_admin::api::*; + +use crate::cli::structs::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn cmd_worker(&self, cmd: WorkerOperation) -> Result<(), Error> { + match cmd { + WorkerOperation::Get { + all_nodes, + variable, + } => self.cmd_get_var(all_nodes, variable).await, + WorkerOperation::Set { + all_nodes, + variable, + value, + } => self.cmd_set_var(all_nodes, variable, value).await, + wo => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::Worker(wo), + ) + .await + .ok_or_message("cli_v1"), + } + } + + pub async fn cmd_get_var(&self, all: bool, var: Option) -> Result<(), Error> { + let res = self + .api_request(GetWorkerVariableRequest { + node: if all { + "*".to_string() + } else { + hex::encode(self.rpc_host) + }, + body: LocalGetWorkerVariableRequest { variable: var }, + }) + .await?; + + let mut table = vec![]; + for (node, vars) in res.success.iter() { + for (key, val) in vars.0.iter() { + table.push(format!("{:.16}\t{}\t{}", node, key, val)); + } + } + format_table(table); + + for (node, err) in res.error.iter() { + eprintln!("{:.16}: error: {}", node, err); + } + + Ok(()) + } + + pub async fn cmd_set_var( + &self, + all: bool, + variable: String, + value: String, + ) -> Result<(), Error> { + let res = self + .api_request(SetWorkerVariableRequest { + node: if all { + "*".to_string() + } else { + hex::encode(self.rpc_host) + }, + body: LocalSetWorkerVariableRequest { variable, value }, + }) + .await?; + + let mut table = vec![]; + for (node, kv) in res.success.iter() { + table.push(format!("{:.16}\t{}\t{}", node, kv.variable, kv.value)); + } + format_table(table); + + for (node, err) in res.error.iter() { + eprintln!("{:.16}: error: {}", node, err); + } + + Ok(()) + } +} diff --git a/src/garage/main.rs b/src/garage/main.rs index 08c7cee7..022841f5 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -35,6 +35,8 @@ use garage_util::error::*; use garage_rpc::system::*; use garage_rpc::*; +use garage_api_admin::api_server::{AdminRpc as ProxyRpc, ADMIN_RPC_PATH as PROXY_RPC_PATH}; + use admin::*; use cli::*; use secrets::Secrets; @@ -282,10 +284,12 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { let system_rpc_endpoint = netapp.endpoint::(SYSTEM_RPC_PATH.into()); let admin_rpc_endpoint = netapp.endpoint::(ADMIN_RPC_PATH.into()); + let proxy_rpc_endpoint = netapp.endpoint::(PROXY_RPC_PATH.into()); let cli = cli_v2::Cli { system_rpc_endpoint, admin_rpc_endpoint, + proxy_rpc_endpoint, rpc_host: id, }; diff --git a/src/garage/server.rs b/src/garage/server.rs index 9e58fa6d..f17f641b 100644 --- a/src/garage/server.rs +++ b/src/garage/server.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::Arc; use tokio::sync::watch; @@ -64,8 +65,9 @@ pub async fn run_server(config_file: PathBuf, secrets: Secrets) -> Result<(), Er } info!("Initialize Admin API server and metrics collector..."); - let admin_server = AdminApiServer::new( + let admin_server: Arc = AdminApiServer::new( garage.clone(), + background.clone(), #[cfg(feature = "metrics")] metrics_exporter, ); From 10bbb26b303e7bd58ca3396009a66b70a1673c0f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 31 Jan 2025 15:39:31 +0100 Subject: [PATCH 042/192] cli_v2: implement ListWorkers and GetWorkerInfo --- src/api/admin/api.rs | 93 +++++++++++++++++++- src/api/admin/error.rs | 7 +- src/api/admin/macros.rs | 12 +-- src/api/admin/router_v2.rs | 3 + src/api/admin/worker.rs | 74 ++++++++++++++++ src/api/common/router_macros.rs | 3 + src/garage/admin/mod.rs | 30 +------ src/garage/cli/cmd.rs | 6 -- src/garage/cli/util.rs | 117 ------------------------- src/garage/cli_v2/worker.rs | 147 ++++++++++++++++++++++++++++++-- src/garage/server.rs | 3 +- src/util/background/mod.rs | 5 +- src/util/background/worker.rs | 14 +-- 13 files changed, 325 insertions(+), 189 deletions(-) diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 89ddb286..1034f59c 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use garage_rpc::*; use garage_model::garage::Garage; +use garage_util::error::Error as GarageError; use garage_api_common::common_error::CommonErrorDerivative; use garage_api_common::helpers::is_default; @@ -78,11 +79,46 @@ admin_endpoints![ RemoveBucketAlias, // Worker operations + ListWorkers, + GetWorkerInfo, GetWorkerVariable, SetWorkerVariable, ]; -local_admin_endpoints![GetWorkerVariable, SetWorkerVariable,]; +local_admin_endpoints![ + // Background workers + ListWorkers, + GetWorkerInfo, + GetWorkerVariable, + SetWorkerVariable, +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiRequest { + pub node: String, + pub body: RB, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiResponse { + pub success: HashMap, + pub error: HashMap, +} + +impl MultiResponse { + pub fn into_single_response(self) -> Result { + if let Some((_, e)) = self.error.into_iter().next() { + return Err(GarageError::Message(e)); + } + if self.success.len() != 1 { + return Err(GarageError::Message(format!( + "{} responses returned, expected 1", + self.success.len() + ))); + } + Ok(self.success.into_iter().next().unwrap().1) + } +} // ********************************************** // Special endpoints @@ -596,6 +632,61 @@ pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); // Worker operations // ********************************************** +// ---- GetWorkerList ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LocalListWorkersRequest { + #[serde(default)] + pub busy_only: bool, + #[serde(default)] + pub error_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalListWorkersResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkerInfoResp { + pub id: u64, + pub name: String, + pub state: WorkerStateResp, + pub errors: u64, + pub consecutive_errors: u64, + pub last_error: Option, + pub tranquility: Option, + pub progress: Option, + pub queue_length: Option, + pub persistent_errors: Option, + pub freeform: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkerStateResp { + Busy, + Throttled { duration_secs: f32 }, + Idle, + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkerLastError { + pub message: String, + pub secs_ago: u64, +} + +// ---- GetWorkerList ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalGetWorkerInfoRequest { + pub id: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalGetWorkerInfoResponse(pub WorkerInfoResp); + // ---- GetWorkerVariable ---- #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index 3712ee7d..354a3bab 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -25,6 +25,10 @@ pub enum Error { #[error(display = "Access key not found: {}", _0)] NoSuchAccessKey(String), + /// The requested worker does not exist + #[error(display = "Worker not found: {}", _0)] + NoSuchWorker(u64), + /// In Import key, the key already exists #[error( display = "Key {} already exists in data store. Even if it is deleted, we can't let you create a new key with the same ID. Sorry.", @@ -53,6 +57,7 @@ impl Error { match self { Error::Common(c) => c.aws_code(), Error::NoSuchAccessKey(_) => "NoSuchAccessKey", + Error::NoSuchWorker(_) => "NoSuchWorker", Error::KeyAlreadyExists(_) => "KeyAlreadyExists", } } @@ -63,7 +68,7 @@ impl ApiError for Error { fn http_status_code(&self) -> StatusCode { match self { Error::Common(c) => c.http_status_code(), - Error::NoSuchAccessKey(_) => StatusCode::NOT_FOUND, + Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) => StatusCode::NOT_FOUND, Error::KeyAlreadyExists(_) => StatusCode::CONFLICT, } } diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index bf7eede9..4b183bec 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -111,19 +111,11 @@ macro_rules! local_admin_endpoints { } $( - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct [< $endpoint Request >] { - pub node: String, - pub body: [< Local $endpoint Request >], - } + pub type [< $endpoint Request >] = MultiRequest< [< Local $endpoint Request >] >; pub type [< $endpoint RequestBody >] = [< Local $endpoint Request >]; - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct [< $endpoint Response >] { - pub success: HashMap] >, - pub error: HashMap, - } + pub type [< $endpoint Response >] = MultiResponse< [< Local $endpoint Response >] >; impl From< [< Local $endpoint Request >] > for LocalAdminApiRequest { fn from(req: [< Local $endpoint Request >]) -> LocalAdminApiRequest { diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index e0ce5b93..6334b3b1 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -60,7 +60,10 @@ impl AdminApiRequest { POST AddBucketAlias (body), POST RemoveBucketAlias (body), // Worker APIs + POST ListWorkers (body_field, query::node), + POST GetWorkerInfo (body_field, query::node), POST GetWorkerVariable (body_field, query::node), + POST SetWorkerVariable (body_field, query::node), ]); if let Some(message) = query.nonempty_message() { diff --git a/src/api/admin/worker.rs b/src/api/admin/worker.rs index 78508175..c7c75700 100644 --- a/src/api/admin/worker.rs +++ b/src/api/admin/worker.rs @@ -3,12 +3,59 @@ use std::sync::Arc; use async_trait::async_trait; +use garage_util::background::*; +use garage_util::time::now_msec; + use garage_model::garage::Garage; use crate::api::*; use crate::error::Error; use crate::{Admin, RequestHandler}; +#[async_trait] +impl RequestHandler for LocalListWorkersRequest { + type Response = LocalListWorkersResponse; + + async fn handle( + self, + _garage: &Arc, + admin: &Admin, + ) -> Result { + let workers = admin.background.get_worker_info(); + let info = workers + .into_iter() + .filter(|(_, w)| { + (!self.busy_only + || matches!(w.state, WorkerState::Busy | WorkerState::Throttled(_))) + && (!self.error_only || w.errors > 0) + }) + .map(|(id, w)| worker_info_to_api(id as u64, w)) + .collect::>(); + Ok(LocalListWorkersResponse(info)) + } +} + +#[async_trait] +impl RequestHandler for LocalGetWorkerInfoRequest { + type Response = LocalGetWorkerInfoResponse; + + async fn handle( + self, + _garage: &Arc, + admin: &Admin, + ) -> Result { + let info = admin + .background + .get_worker_info() + .get(&(self.id as usize)) + .ok_or(Error::NoSuchWorker(self.id))? + .clone(); + Ok(LocalGetWorkerInfoResponse(worker_info_to_api( + self.id, info, + ))) + } +} + #[async_trait] impl RequestHandler for LocalGetWorkerVariableRequest { type Response = LocalGetWorkerVariableResponse; @@ -48,3 +95,30 @@ impl RequestHandler for LocalSetWorkerVariableRequest { }) } } + +// ---- helper functions ---- + +fn worker_info_to_api(id: u64, info: WorkerInfo) -> WorkerInfoResp { + WorkerInfoResp { + id: id, + name: info.name, + state: match info.state { + WorkerState::Busy => WorkerStateResp::Busy, + WorkerState::Throttled(t) => WorkerStateResp::Throttled { duration_secs: t }, + WorkerState::Idle => WorkerStateResp::Idle, + WorkerState::Done => WorkerStateResp::Done, + }, + errors: info.errors as u64, + consecutive_errors: info.consecutive_errors as u64, + last_error: info.last_error.map(|(message, t)| WorkerLastError { + message, + secs_ago: (std::cmp::max(t, now_msec()) - t) / 1000, + }), + + tranquility: info.status.tranquility, + progress: info.status.progress, + queue_length: info.status.queue_length, + persistent_errors: info.status.persistent_errors, + freeform: info.status.freeform, + } +} diff --git a/src/api/common/router_macros.rs b/src/api/common/router_macros.rs index 299420f7..f4a93c67 100644 --- a/src/api/common/router_macros.rs +++ b/src/api/common/router_macros.rs @@ -141,6 +141,9 @@ macro_rules! router_match { } }}; + (@@parse_param $query:expr, default, $param:ident) => {{ + Default::default() + }}; (@@parse_param $query:expr, query_opt, $param:ident) => {{ // extract optional query parameter $query.$param.take().map(|param| param.into_owned()) diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index f493d0c5..c0e63524 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -22,7 +22,7 @@ use garage_rpc::*; use garage_block::manager::BlockResyncErrorInfo; use garage_model::garage::Garage; -use garage_model::helper::error::{Error, OkOrBadRequest}; +use garage_model::helper::error::Error; use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::Version; @@ -40,17 +40,11 @@ pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; pub enum AdminRpc { LaunchRepair(RepairOpt), Stats(StatsOpt), - Worker(WorkerOperation), BlockOperation(BlockOperation), MetaOperation(MetaOperation), // Replies Ok(String), - WorkerList( - HashMap, - WorkerListOpt, - ), - WorkerInfo(usize, garage_util::background::WorkerInfo), BlockErrorList(Vec), BlockInfo { hash: Hash, @@ -340,27 +334,6 @@ impl AdminRpcHandler { )) } - // ================ WORKER COMMANDS ==================== - - async fn handle_worker_cmd(&self, cmd: &WorkerOperation) -> Result { - match cmd { - WorkerOperation::List { opt } => { - let workers = self.background.get_worker_info(); - Ok(AdminRpc::WorkerList(workers, *opt)) - } - WorkerOperation::Info { tid } => { - let info = self - .background - .get_worker_info() - .get(tid) - .ok_or_bad_request(format!("No worker with TID {}", tid))? - .clone(); - Ok(AdminRpc::WorkerInfo(*tid, info)) - } - _ => unreachable!(), - } - } - // ================ META DB COMMANDS ==================== async fn handle_meta_cmd(self: &Arc, mo: &MetaOperation) -> Result { @@ -409,7 +382,6 @@ impl EndpointHandler for AdminRpcHandler { match message { AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, AdminRpc::Stats(opt) => self.handle_stats(opt.clone()).await, - AdminRpc::Worker(wo) => self.handle_worker_cmd(wo).await, AdminRpc::BlockOperation(bo) => self.handle_block_cmd(bo).await, AdminRpc::MetaOperation(mo) => self.handle_meta_cmd(mo).await, m => Err(GarageError::unexpected_rpc_message(m).into()), diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index 6f1b0681..bc34d014 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -17,12 +17,6 @@ pub async fn cmd_admin( AdminRpc::Ok(msg) => { println!("{}", msg); } - AdminRpc::WorkerList(wi, wlo) => { - print_worker_list(wi, wlo); - } - AdminRpc::WorkerInfo(tid, wi) => { - print_worker_info(tid, wi); - } AdminRpc::BlockErrorList(el) => { print_block_error_list(el); } diff --git a/src/garage/cli/util.rs b/src/garage/cli/util.rs index 8261fb3e..43b28623 100644 --- a/src/garage/cli/util.rs +++ b/src/garage/cli/util.rs @@ -1,8 +1,6 @@ -use std::collections::HashMap; use std::time::Duration; use format_table::format_table; -use garage_util::background::*; use garage_util::data::*; use garage_util::time::*; @@ -11,121 +9,6 @@ use garage_block::manager::BlockResyncErrorInfo; use garage_model::s3::mpu_table::MultipartUpload; use garage_model::s3::version_table::*; -use crate::cli::structs::WorkerListOpt; - -pub fn print_worker_list(wi: HashMap, wlo: WorkerListOpt) { - let mut wi = wi.into_iter().collect::>(); - wi.sort_by_key(|(tid, info)| { - ( - match info.state { - WorkerState::Busy | WorkerState::Throttled(_) => 0, - WorkerState::Idle => 1, - WorkerState::Done => 2, - }, - *tid, - ) - }); - - let mut table = vec!["TID\tState\tName\tTranq\tDone\tQueue\tErrors\tConsec\tLast".to_string()]; - for (tid, info) in wi.iter() { - if wlo.busy && !matches!(info.state, WorkerState::Busy | WorkerState::Throttled(_)) { - continue; - } - if wlo.errors && info.errors == 0 { - continue; - } - - let tf = timeago::Formatter::new(); - let err_ago = info - .last_error - .as_ref() - .map(|(_, t)| tf.convert(Duration::from_millis(now_msec() - t))) - .unwrap_or_default(); - let (total_err, consec_err) = if info.errors > 0 { - (info.errors.to_string(), info.consecutive_errors.to_string()) - } else { - ("-".into(), "-".into()) - }; - - table.push(format!( - "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", - tid, - info.state, - info.name, - info.status - .tranquility - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "-".into()), - info.status.progress.as_deref().unwrap_or("-"), - info.status - .queue_length - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "-".into()), - total_err, - consec_err, - err_ago, - )); - } - format_table(table); -} - -pub fn print_worker_info(tid: usize, info: WorkerInfo) { - let mut table = vec![]; - table.push(format!("Task id:\t{}", tid)); - table.push(format!("Worker name:\t{}", info.name)); - match info.state { - WorkerState::Throttled(t) => { - table.push(format!( - "Worker state:\tBusy (throttled, paused for {:.3}s)", - t - )); - } - s => { - table.push(format!("Worker state:\t{}", s)); - } - }; - if let Some(tql) = info.status.tranquility { - table.push(format!("Tranquility:\t{}", tql)); - } - - table.push("".into()); - table.push(format!("Total errors:\t{}", info.errors)); - table.push(format!("Consecutive errs:\t{}", info.consecutive_errors)); - if let Some((s, t)) = info.last_error { - table.push(format!("Last error:\t{}", s)); - let tf = timeago::Formatter::new(); - table.push(format!( - "Last error time:\t{}", - tf.convert(Duration::from_millis(now_msec() - t)) - )); - } - - table.push("".into()); - if let Some(p) = info.status.progress { - table.push(format!("Progress:\t{}", p)); - } - if let Some(ql) = info.status.queue_length { - table.push(format!("Queue length:\t{}", ql)); - } - if let Some(pe) = info.status.persistent_errors { - table.push(format!("Persistent errors:\t{}", pe)); - } - - for (i, s) in info.status.freeform.iter().enumerate() { - if i == 0 { - if table.last() != Some(&"".into()) { - table.push("".into()); - } - table.push(format!("Message:\t{}", s)); - } else { - table.push(format!("\t{}", s)); - } - } - format_table(table); -} - pub fn print_block_error_list(el: Vec) { let now = now_msec(); let tf = timeago::Formatter::new(); diff --git a/src/garage/cli_v2/worker.rs b/src/garage/cli_v2/worker.rs index 0dfe3e96..9db729ec 100644 --- a/src/garage/cli_v2/worker.rs +++ b/src/garage/cli_v2/worker.rs @@ -11,6 +11,8 @@ use crate::cli_v2::*; impl Cli { pub async fn cmd_worker(&self, cmd: WorkerOperation) -> Result<(), Error> { match cmd { + WorkerOperation::List { opt } => self.cmd_list_workers(opt).await, + WorkerOperation::Info { tid } => self.cmd_worker_info(tid).await, WorkerOperation::Get { all_nodes, variable, @@ -20,16 +22,138 @@ impl Cli { variable, value, } => self.cmd_set_var(all_nodes, variable, value).await, - wo => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::Worker(wo), - ) - .await - .ok_or_message("cli_v1"), } } + pub async fn cmd_list_workers(&self, opt: WorkerListOpt) -> Result<(), Error> { + let mut list = self + .api_request(ListWorkersRequest { + node: hex::encode(self.rpc_host), + body: LocalListWorkersRequest { + busy_only: opt.busy, + error_only: opt.errors, + }, + }) + .await? + .into_single_response()? + .0; + + list.sort_by_key(|info| { + ( + match info.state { + WorkerStateResp::Busy | WorkerStateResp::Throttled { .. } => 0, + WorkerStateResp::Idle => 1, + WorkerStateResp::Done => 2, + }, + info.id, + ) + }); + + let mut table = + vec!["TID\tState\tName\tTranq\tDone\tQueue\tErrors\tConsec\tLast".to_string()]; + let tf = timeago::Formatter::new(); + for info in list.iter() { + let err_ago = info + .last_error + .as_ref() + .map(|x| tf.convert(Duration::from_secs(x.secs_ago))) + .unwrap_or_default(); + let (total_err, consec_err) = if info.errors > 0 { + (info.errors.to_string(), info.consecutive_errors.to_string()) + } else { + ("-".into(), "-".into()) + }; + + table.push(format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + info.id, + format_worker_state(&info.state), + info.name, + info.tranquility + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "-".into()), + info.progress.as_deref().unwrap_or("-"), + info.queue_length + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "-".into()), + total_err, + consec_err, + err_ago, + )); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_worker_info(&self, tid: usize) -> Result<(), Error> { + let info = self + .api_request(GetWorkerInfoRequest { + node: hex::encode(self.rpc_host), + body: LocalGetWorkerInfoRequest { id: tid as u64 }, + }) + .await? + .into_single_response()? + .0; + + let mut table = vec![]; + table.push(format!("Task id:\t{}", info.id)); + table.push(format!("Worker name:\t{}", info.name)); + match &info.state { + WorkerStateResp::Throttled { duration_secs } => { + table.push(format!( + "Worker state:\tBusy (throttled, paused for {:.3}s)", + duration_secs + )); + } + s => { + table.push(format!("Worker state:\t{}", format_worker_state(s))); + } + }; + if let Some(tql) = info.tranquility { + table.push(format!("Tranquility:\t{}", tql)); + } + + table.push("".into()); + table.push(format!("Total errors:\t{}", info.errors)); + table.push(format!("Consecutive errs:\t{}", info.consecutive_errors)); + if let Some(err) = info.last_error { + table.push(format!("Last error:\t{}", err.message)); + let tf = timeago::Formatter::new(); + table.push(format!( + "Last error time:\t{}", + tf.convert(Duration::from_secs(err.secs_ago)) + )); + } + + table.push("".into()); + if let Some(p) = info.progress { + table.push(format!("Progress:\t{}", p)); + } + if let Some(ql) = info.queue_length { + table.push(format!("Queue length:\t{}", ql)); + } + if let Some(pe) = info.persistent_errors { + table.push(format!("Persistent errors:\t{}", pe)); + } + + for (i, s) in info.freeform.iter().enumerate() { + if i == 0 { + if table.last() != Some(&"".into()) { + table.push("".into()); + } + table.push(format!("Message:\t{}", s)); + } else { + table.push(format!("\t{}", s)); + } + } + format_table(table); + + Ok(()) + } + pub async fn cmd_get_var(&self, all: bool, var: Option) -> Result<(), Error> { let res = self .api_request(GetWorkerVariableRequest { @@ -87,3 +211,12 @@ impl Cli { Ok(()) } } + +fn format_worker_state(s: &WorkerStateResp) -> &'static str { + match s { + WorkerStateResp::Busy => "Busy", + WorkerStateResp::Throttled { .. } => "Busy*", + WorkerStateResp::Idle => "Idle", + WorkerStateResp::Done => "Done", + } +} diff --git a/src/garage/server.rs b/src/garage/server.rs index f17f641b..e629041c 100644 --- a/src/garage/server.rs +++ b/src/garage/server.rs @@ -1,5 +1,4 @@ use std::path::PathBuf; -use std::sync::Arc; use tokio::sync::watch; @@ -65,7 +64,7 @@ pub async fn run_server(config_file: PathBuf, secrets: Secrets) -> Result<(), Er } info!("Initialize Admin API server and metrics collector..."); - let admin_server: Arc = AdminApiServer::new( + let admin_server = AdminApiServer::new( garage.clone(), background.clone(), #[cfg(feature = "metrics")] diff --git a/src/util/background/mod.rs b/src/util/background/mod.rs index 607cd7a3..cae3a462 100644 --- a/src/util/background/mod.rs +++ b/src/util/background/mod.rs @@ -6,7 +6,6 @@ pub mod worker; use std::collections::HashMap; use std::sync::Arc; -use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, watch}; use worker::WorkerProcessor; @@ -18,7 +17,7 @@ pub struct BackgroundRunner { worker_info: Arc>>, } -#[derive(Clone, Serialize, Deserialize, Debug)] +#[derive(Clone, Debug)] pub struct WorkerInfo { pub name: String, pub status: WorkerStatus, @@ -30,7 +29,7 @@ pub struct WorkerInfo { /// WorkerStatus is a struct returned by the worker with a bunch of canonical /// fields to indicate their status to CLI users. All fields are optional. -#[derive(Clone, Serialize, Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct WorkerStatus { pub tranquility: Option, pub progress: Option, diff --git a/src/util/background/worker.rs b/src/util/background/worker.rs index 76fb14e8..9028a052 100644 --- a/src/util/background/worker.rs +++ b/src/util/background/worker.rs @@ -6,7 +6,6 @@ use async_trait::async_trait; use futures::future::*; use futures::stream::FuturesUnordered; use futures::StreamExt; -use serde::{Deserialize, Serialize}; use tokio::select; use tokio::sync::{mpsc, watch}; @@ -18,7 +17,7 @@ use crate::time::now_msec; // will be interrupted in the middle of whatever they are doing. const EXIT_DEADLINE: Duration = Duration::from_secs(8); -#[derive(PartialEq, Copy, Clone, Serialize, Deserialize, Debug)] +#[derive(PartialEq, Copy, Clone, Debug)] pub enum WorkerState { Busy, Throttled(f32), @@ -26,17 +25,6 @@ pub enum WorkerState { Done, } -impl std::fmt::Display for WorkerState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - WorkerState::Busy => write!(f, "Busy"), - WorkerState::Throttled(_) => write!(f, "Busy*"), - WorkerState::Idle => write!(f, "Idle"), - WorkerState::Done => write!(f, "Done"), - } - } -} - #[async_trait] pub trait Worker: Send { fn name(&self) -> String; From 7b9c047b113d78dacbece4670b8a1a1cbd771849 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 31 Jan 2025 15:53:02 +0100 Subject: [PATCH 043/192] cli_v2: add local_api_request with crazy type bound --- src/api/admin/api.rs | 16 ---------------- src/garage/cli_v2/mod.rs | 38 ++++++++++++++++++++++++++++++++----- src/garage/cli_v2/worker.rs | 16 ++++------------ 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 1034f59c..cf136d28 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize}; use garage_rpc::*; use garage_model::garage::Garage; -use garage_util::error::Error as GarageError; use garage_api_common::common_error::CommonErrorDerivative; use garage_api_common::helpers::is_default; @@ -105,21 +104,6 @@ pub struct MultiResponse { pub error: HashMap, } -impl MultiResponse { - pub fn into_single_response(self) -> Result { - if let Some((_, e)) = self.error.into_iter().next() { - return Err(GarageError::Message(e)); - } - if self.success.len() != 1 { - return Err(GarageError::Message(format!( - "{} responses returned, expected 1", - self.success.len() - ))); - } - Ok(self.success.into_iter().next().unwrap().1) - } -} - // ********************************************** // Special endpoints // diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index b9bf05fe..b175ab38 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -16,7 +16,7 @@ use garage_rpc::*; use garage_api_admin::api::*; use garage_api_admin::api_server::{AdminRpc as ProxyRpc, AdminRpcResponse as ProxyRpcResponse}; -use garage_api_admin::RequestHandler as AdminApiEndpoint; +use garage_api_admin::RequestHandler; use crate::admin::*; use crate::cli as cli_v1; @@ -74,11 +74,11 @@ impl Cli { } } - pub async fn api_request(&self, req: T) -> Result<::Response, Error> + pub async fn api_request(&self, req: T) -> Result<::Response, Error> where - T: AdminApiEndpoint, + T: RequestHandler, AdminApiRequest: From, - ::Response: TryFrom, + ::Response: TryFrom, { let req = AdminApiRequest::from(req); let req_name = req.name(); @@ -88,7 +88,7 @@ impl Cli { .await?? { ProxyRpcResponse::ProxyApiOkResponse(resp) => { - ::Response::try_from(resp).map_err(|_| { + ::Response::try_from(resp).map_err(|_| { Error::Message(format!("{} returned unexpected response", req_name)) }) } @@ -103,4 +103,32 @@ impl Cli { m => Err(Error::unexpected_rpc_message(m)), } } + + pub async fn local_api_request( + &self, + req: T, + ) -> Result<::Response, Error> + where + T: RequestHandler, + MultiRequest: RequestHandler::Response>>, + AdminApiRequest: From>, + as RequestHandler>::Response: TryFrom, + { + let req = MultiRequest { + node: hex::encode(self.rpc_host), + body: req, + }; + let resp = self.api_request(req).await?; + + if let Some((_, e)) = resp.error.into_iter().next() { + return Err(Error::Message(e)); + } + if resp.success.len() != 1 { + return Err(Error::Message(format!( + "{} responses returned, expected 1", + resp.success.len() + ))); + } + Ok(resp.success.into_iter().next().unwrap().1) + } } diff --git a/src/garage/cli_v2/worker.rs b/src/garage/cli_v2/worker.rs index 9db729ec..b94a4f68 100644 --- a/src/garage/cli_v2/worker.rs +++ b/src/garage/cli_v2/worker.rs @@ -27,15 +27,11 @@ impl Cli { pub async fn cmd_list_workers(&self, opt: WorkerListOpt) -> Result<(), Error> { let mut list = self - .api_request(ListWorkersRequest { - node: hex::encode(self.rpc_host), - body: LocalListWorkersRequest { - busy_only: opt.busy, - error_only: opt.errors, - }, + .local_api_request(LocalListWorkersRequest { + busy_only: opt.busy, + error_only: opt.errors, }) .await? - .into_single_response()? .0; list.sort_by_key(|info| { @@ -90,12 +86,8 @@ impl Cli { pub async fn cmd_worker_info(&self, tid: usize) -> Result<(), Error> { let info = self - .api_request(GetWorkerInfoRequest { - node: hex::encode(self.rpc_host), - body: LocalGetWorkerInfoRequest { id: tid as u64 }, - }) + .local_api_request(LocalGetWorkerInfoRequest { id: tid as u64 }) .await? - .into_single_response()? .0; let mut table = vec![]; From d405a9f839779b1454e47e4b53a418603061c5e9 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 31 Jan 2025 16:53:33 +0100 Subject: [PATCH 044/192] cli_v2: implement ListBlockErrors and GetBlockInfo --- src/api/admin/api.rs | 71 +++++++++++++++++ src/api/admin/block.rs | 149 ++++++++++++++++++++++++++++++++++++ src/api/admin/error.rs | 9 ++- src/api/admin/lib.rs | 1 + src/api/admin/router_v2.rs | 3 + src/api/admin/worker.rs | 4 +- src/garage/admin/block.rs | 84 +------------------- src/garage/admin/mod.rs | 11 --- src/garage/cli/cmd.rs | 12 --- src/garage/cli/mod.rs | 2 - src/garage/cli/util.rs | 91 ---------------------- src/garage/cli_v2/block.rs | 109 ++++++++++++++++++++++++++ src/garage/cli_v2/mod.rs | 9 +-- src/garage/cli_v2/worker.rs | 1 - 14 files changed, 346 insertions(+), 210 deletions(-) create mode 100644 src/api/admin/block.rs delete mode 100644 src/garage/cli/util.rs create mode 100644 src/garage/cli_v2/block.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index cf136d28..42872ad0 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -82,6 +82,10 @@ admin_endpoints![ GetWorkerInfo, GetWorkerVariable, SetWorkerVariable, + + // Block operations + ListBlockErrors, + GetBlockInfo, ]; local_admin_endpoints![ @@ -90,6 +94,9 @@ local_admin_endpoints![ GetWorkerInfo, GetWorkerVariable, SetWorkerVariable, + // Block operations + ListBlockErrors, + GetBlockInfo, ]; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -619,6 +626,7 @@ pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); // ---- GetWorkerList ---- #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct LocalListWorkersRequest { #[serde(default)] pub busy_only: bool, @@ -694,3 +702,66 @@ pub struct LocalSetWorkerVariableResponse { pub variable: String, pub value: String, } + +// ********************************************** +// Block operations +// ********************************************** + +// ---- ListBlockErrors ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LocalListBlockErrorsRequest; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalListBlockErrorsResponse(pub Vec); + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct BlockError { + pub block_hash: String, + pub refcount: u64, + pub error_count: u64, + pub last_try_secs_ago: u64, + pub next_try_in_secs: u64, +} + +// ---- GetBlockInfo ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalGetBlockInfoRequest { + pub block_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalGetBlockInfoResponse { + pub block_hash: String, + pub refcount: u64, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockVersion { + pub version_id: String, + pub deleted: bool, + pub garbage_collected: bool, + pub backlink: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlockVersionBacklink { + Object { + bucket_id: String, + key: String, + }, + Upload { + upload_id: String, + upload_deleted: bool, + upload_garbage_collected: bool, + bucket_id: Option, + key: Option, + }, +} diff --git a/src/api/admin/block.rs b/src/api/admin/block.rs new file mode 100644 index 00000000..157db5b5 --- /dev/null +++ b/src/api/admin/block.rs @@ -0,0 +1,149 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use garage_util::data::*; +use garage_util::error::Error as GarageError; +use garage_util::time::now_msec; + +use garage_table::EmptyKey; + +use garage_model::garage::Garage; +use garage_model::s3::version_table::*; + +use crate::admin::api::*; +use crate::admin::error::*; +use crate::admin::{Admin, RequestHandler}; +use crate::common_error::CommonErrorDerivative; + +#[async_trait] +impl RequestHandler for LocalListBlockErrorsRequest { + type Response = LocalListBlockErrorsResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let errors = garage.block_manager.list_resync_errors()?; + let now = now_msec(); + let errors = errors + .into_iter() + .map(|e| BlockError { + block_hash: hex::encode(&e.hash), + refcount: e.refcount, + error_count: e.error_count, + last_try_secs_ago: now.saturating_sub(e.last_try) / 1000, + next_try_in_secs: e.next_try.saturating_sub(now) / 1000, + }) + .collect(); + Ok(LocalListBlockErrorsResponse(errors)) + } +} + +#[async_trait] +impl RequestHandler for LocalGetBlockInfoRequest { + type Response = LocalGetBlockInfoResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let hash = find_block_hash_by_prefix(garage, &self.block_hash)?; + let refcount = garage.block_manager.get_block_rc(&hash)?; + let block_refs = garage + .block_ref_table + .get_range(&hash, None, None, 10000, Default::default()) + .await?; + let mut versions = vec![]; + for br in block_refs { + if let Some(v) = garage.version_table.get(&br.version, &EmptyKey).await? { + let bl = match &v.backlink { + VersionBacklink::MultipartUpload { upload_id } => { + if let Some(u) = garage.mpu_table.get(upload_id, &EmptyKey).await? { + BlockVersionBacklink::Upload { + upload_id: hex::encode(&upload_id), + upload_deleted: u.deleted.get(), + upload_garbage_collected: false, + bucket_id: Some(hex::encode(&u.bucket_id)), + key: Some(u.key.to_string()), + } + } else { + BlockVersionBacklink::Upload { + upload_id: hex::encode(&upload_id), + upload_deleted: true, + upload_garbage_collected: true, + bucket_id: None, + key: None, + } + } + } + VersionBacklink::Object { bucket_id, key } => BlockVersionBacklink::Object { + bucket_id: hex::encode(&bucket_id), + key: key.to_string(), + }, + }; + versions.push(BlockVersion { + version_id: hex::encode(&br.version), + deleted: v.deleted.get(), + garbage_collected: false, + backlink: Some(bl), + }); + } else { + versions.push(BlockVersion { + version_id: hex::encode(&br.version), + deleted: true, + garbage_collected: true, + backlink: None, + }); + } + } + Ok(LocalGetBlockInfoResponse { + block_hash: hex::encode(&hash), + refcount, + versions, + }) + } +} + +fn find_block_hash_by_prefix(garage: &Arc, prefix: &str) -> Result { + if prefix.len() < 4 { + return Err(Error::bad_request( + "Please specify at least 4 characters of the block hash", + )); + } + + let prefix_bin = hex::decode(&prefix[..prefix.len() & !1]).ok_or_bad_request("invalid hash")?; + + let iter = garage + .block_ref_table + .data + .store + .range(&prefix_bin[..]..) + .map_err(GarageError::from)?; + let mut found = None; + for item in iter { + let (k, _v) = item.map_err(GarageError::from)?; + let hash = Hash::try_from(&k[..32]).unwrap(); + if &hash.as_slice()[..prefix_bin.len()] != prefix_bin { + break; + } + if hex::encode(hash.as_slice()).starts_with(prefix) { + match &found { + Some(x) if *x == hash => (), + Some(_) => { + return Err(Error::bad_request(format!( + "Several blocks match prefix `{}`", + prefix + ))); + } + None => { + found = Some(hash); + } + } + } + } + + found.ok_or_else(|| Error::NoSuchBlock(prefix.to_string())) +} diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index 354a3bab..d7ea7dc9 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -25,6 +25,10 @@ pub enum Error { #[error(display = "Access key not found: {}", _0)] NoSuchAccessKey(String), + /// The requested block does not exist + #[error(display = "Block not found: {}", _0)] + NoSuchBlock(String), + /// The requested worker does not exist #[error(display = "Worker not found: {}", _0)] NoSuchWorker(u64), @@ -58,6 +62,7 @@ impl Error { Error::Common(c) => c.aws_code(), Error::NoSuchAccessKey(_) => "NoSuchAccessKey", Error::NoSuchWorker(_) => "NoSuchWorker", + Error::NoSuchBlock(_) => "NoSuchBlock", Error::KeyAlreadyExists(_) => "KeyAlreadyExists", } } @@ -68,7 +73,9 @@ impl ApiError for Error { fn http_status_code(&self) -> StatusCode { match self { Error::Common(c) => c.http_status_code(), - Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) => StatusCode::NOT_FOUND, + Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) | Error::NoSuchBlock(_) => { + StatusCode::NOT_FOUND + } Error::KeyAlreadyExists(_) => StatusCode::CONFLICT, } } diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index 4ad10532..e7ee37af 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -15,6 +15,7 @@ mod cluster; mod key; mod special; +mod block; mod worker; use std::sync::Arc; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 6334b3b1..5c6cb29c 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -64,6 +64,9 @@ impl AdminApiRequest { POST GetWorkerInfo (body_field, query::node), POST GetWorkerVariable (body_field, query::node), POST SetWorkerVariable (body_field, query::node), + // Block APIs + GET ListBlockErrors (default::body, query::node), + POST GetBlockInfo (body_field, query::node), ]); if let Some(message) = query.nonempty_message() { diff --git a/src/api/admin/worker.rs b/src/api/admin/worker.rs index c7c75700..d143e5be 100644 --- a/src/api/admin/worker.rs +++ b/src/api/admin/worker.rs @@ -100,7 +100,7 @@ impl RequestHandler for LocalSetWorkerVariableRequest { fn worker_info_to_api(id: u64, info: WorkerInfo) -> WorkerInfoResp { WorkerInfoResp { - id: id, + id, name: info.name, state: match info.state { WorkerState::Busy => WorkerStateResp::Busy, @@ -112,7 +112,7 @@ fn worker_info_to_api(id: u64, info: WorkerInfo) -> WorkerInfoResp { consecutive_errors: info.consecutive_errors as u64, last_error: info.last_error.map(|(message, t)| WorkerLastError { message, - secs_ago: (std::cmp::max(t, now_msec()) - t) / 1000, + secs_ago: now_msec().saturating_sub(t) / 1000, }), tranquility: info.status.tranquility, diff --git a/src/garage/admin/block.rs b/src/garage/admin/block.rs index edeb88c0..1138703a 100644 --- a/src/garage/admin/block.rs +++ b/src/garage/admin/block.rs @@ -13,52 +13,14 @@ use super::*; impl AdminRpcHandler { pub(super) async fn handle_block_cmd(&self, cmd: &BlockOperation) -> Result { match cmd { - BlockOperation::ListErrors => Ok(AdminRpc::BlockErrorList( - self.garage.block_manager.list_resync_errors()?, - )), - BlockOperation::Info { hash } => self.handle_block_info(hash).await, BlockOperation::RetryNow { all, blocks } => { self.handle_block_retry_now(*all, blocks).await } BlockOperation::Purge { yes, blocks } => self.handle_block_purge(*yes, blocks).await, + _ => unreachable!(), } } - async fn handle_block_info(&self, hash: &String) -> Result { - let hash = self.find_block_hash_by_prefix(hash)?; - let refcount = self.garage.block_manager.get_block_rc(&hash)?; - let block_refs = self - .garage - .block_ref_table - .get_range(&hash, None, None, 10000, Default::default()) - .await?; - let mut versions = vec![]; - let mut uploads = vec![]; - for br in block_refs { - if let Some(v) = self - .garage - .version_table - .get(&br.version, &EmptyKey) - .await? - { - if let VersionBacklink::MultipartUpload { upload_id } = &v.backlink { - if let Some(u) = self.garage.mpu_table.get(upload_id, &EmptyKey).await? { - uploads.push(u); - } - } - versions.push(Ok(v)); - } else { - versions.push(Err(br.version)); - } - } - Ok(AdminRpc::BlockInfo { - hash, - refcount, - versions, - uploads, - }) - } - async fn handle_block_retry_now( &self, all: bool, @@ -188,48 +150,4 @@ impl AdminRpcHandler { Ok(()) } - - // ---- helper function ---- - fn find_block_hash_by_prefix(&self, prefix: &str) -> Result { - if prefix.len() < 4 { - return Err(Error::BadRequest( - "Please specify at least 4 characters of the block hash".into(), - )); - } - - let prefix_bin = - hex::decode(&prefix[..prefix.len() & !1]).ok_or_bad_request("invalid hash")?; - - let iter = self - .garage - .block_ref_table - .data - .store - .range(&prefix_bin[..]..) - .map_err(GarageError::from)?; - let mut found = None; - for item in iter { - let (k, _v) = item.map_err(GarageError::from)?; - let hash = Hash::try_from(&k[..32]).unwrap(); - if &hash.as_slice()[..prefix_bin.len()] != prefix_bin { - break; - } - if hex::encode(hash.as_slice()).starts_with(prefix) { - match &found { - Some(x) if *x == hash => (), - Some(_) => { - return Err(Error::BadRequest(format!( - "Several blocks match prefix `{}`", - prefix - ))); - } - None => { - found = Some(hash); - } - } - } - } - - found.ok_or_else(|| Error::BadRequest("No matching block found".into())) - } } diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index c0e63524..1aa9482c 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -19,12 +19,8 @@ use garage_table::*; use garage_rpc::layout::PARTITION_BITS; use garage_rpc::*; -use garage_block::manager::BlockResyncErrorInfo; - use garage_model::garage::Garage; use garage_model::helper::error::Error; -use garage_model::s3::mpu_table::MultipartUpload; -use garage_model::s3::version_table::Version; use garage_api_admin::api::{AdminApiRequest, TaggedAdminApiResponse}; use garage_api_admin::RequestHandler as AdminApiEndpoint; @@ -45,13 +41,6 @@ pub enum AdminRpc { // Replies Ok(String), - BlockErrorList(Vec), - BlockInfo { - hash: Hash, - refcount: u64, - versions: Vec>, - uploads: Vec, - }, } impl Rpc for AdminRpc { diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index bc34d014..e5af461c 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -6,7 +6,6 @@ use garage_rpc::*; use garage_model::helper::error::Error as HelperError; use crate::admin::*; -use crate::cli::*; pub async fn cmd_admin( rpc_cli: &Endpoint, @@ -17,17 +16,6 @@ pub async fn cmd_admin( AdminRpc::Ok(msg) => { println!("{}", msg); } - AdminRpc::BlockErrorList(el) => { - print_block_error_list(el); - } - AdminRpc::BlockInfo { - hash, - refcount, - versions, - uploads, - } => { - print_block_info(hash, refcount, versions, uploads); - } r => { error!("Unexpected response: {:?}", r); } diff --git a/src/garage/cli/mod.rs b/src/garage/cli/mod.rs index 30f566e2..c15afda1 100644 --- a/src/garage/cli/mod.rs +++ b/src/garage/cli/mod.rs @@ -2,11 +2,9 @@ pub(crate) mod cmd; pub(crate) mod init; pub(crate) mod layout; pub(crate) mod structs; -pub(crate) mod util; pub(crate) mod convert_db; pub(crate) use cmd::*; pub(crate) use init::*; pub(crate) use structs::*; -pub(crate) use util::*; diff --git a/src/garage/cli/util.rs b/src/garage/cli/util.rs deleted file mode 100644 index 43b28623..00000000 --- a/src/garage/cli/util.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::time::Duration; - -use format_table::format_table; -use garage_util::data::*; -use garage_util::time::*; - -use garage_block::manager::BlockResyncErrorInfo; - -use garage_model::s3::mpu_table::MultipartUpload; -use garage_model::s3::version_table::*; - -pub fn print_block_error_list(el: Vec) { - let now = now_msec(); - let tf = timeago::Formatter::new(); - let mut tf2 = timeago::Formatter::new(); - tf2.ago(""); - - let mut table = vec!["Hash\tRC\tErrors\tLast error\tNext try".into()]; - for e in el { - let next_try = if e.next_try > now { - tf2.convert(Duration::from_millis(e.next_try - now)) - } else { - "asap".to_string() - }; - table.push(format!( - "{}\t{}\t{}\t{}\tin {}", - hex::encode(e.hash.as_slice()), - e.refcount, - e.error_count, - tf.convert(Duration::from_millis(now - e.last_try)), - next_try - )); - } - format_table(table); -} - -pub fn print_block_info( - hash: Hash, - refcount: u64, - versions: Vec>, - uploads: Vec, -) { - println!("Block hash: {}", hex::encode(hash.as_slice())); - println!("Refcount: {}", refcount); - println!(); - - let mut table = vec!["Version\tBucket\tKey\tMPU\tDeleted".into()]; - let mut nondeleted_count = 0; - for v in versions.iter() { - match v { - Ok(ver) => { - match &ver.backlink { - VersionBacklink::Object { bucket_id, key } => { - table.push(format!( - "{:?}\t{:?}\t{}\t\t{:?}", - ver.uuid, - bucket_id, - key, - ver.deleted.get() - )); - } - VersionBacklink::MultipartUpload { upload_id } => { - let upload = uploads.iter().find(|x| x.upload_id == *upload_id); - table.push(format!( - "{:?}\t{:?}\t{}\t{:?}\t{:?}", - ver.uuid, - upload.map(|u| u.bucket_id).unwrap_or_default(), - upload.map(|u| u.key.as_str()).unwrap_or_default(), - upload_id, - ver.deleted.get() - )); - } - } - if !ver.deleted.get() { - nondeleted_count += 1; - } - } - Err(vh) => { - table.push(format!("{:?}\t\t\t\tyes", vh)); - } - } - } - format_table(table); - - if refcount != nondeleted_count { - println!(); - println!( - "Warning: refcount does not match number of non-deleted versions, you should try `garage repair block-rc`." - ); - } -} diff --git a/src/garage/cli_v2/block.rs b/src/garage/cli_v2/block.rs new file mode 100644 index 00000000..ff3c79e9 --- /dev/null +++ b/src/garage/cli_v2/block.rs @@ -0,0 +1,109 @@ +//use bytesize::ByteSize; +use format_table::format_table; + +use garage_util::error::*; + +use garage_api::admin::api::*; + +use crate::cli::structs::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn cmd_block(&self, cmd: BlockOperation) -> Result<(), Error> { + match cmd { + BlockOperation::ListErrors => self.cmd_list_block_errors().await, + BlockOperation::Info { hash } => self.cmd_get_block_info(hash).await, + + bo => cli_v1::cmd_admin( + &self.admin_rpc_endpoint, + self.rpc_host, + AdminRpc::BlockOperation(bo), + ) + .await + .ok_or_message("cli_v1"), + } + } + + pub async fn cmd_list_block_errors(&self) -> Result<(), Error> { + let errors = self.local_api_request(LocalListBlockErrorsRequest).await?.0; + + let tf = timeago::Formatter::new(); + let mut tf2 = timeago::Formatter::new(); + tf2.ago(""); + + let mut table = vec!["Hash\tRC\tErrors\tLast error\tNext try".into()]; + for e in errors { + let next_try = if e.next_try_in_secs > 0 { + tf2.convert(Duration::from_secs(e.next_try_in_secs)) + } else { + "asap".to_string() + }; + table.push(format!( + "{}\t{}\t{}\t{}\tin {}", + e.block_hash, + e.refcount, + e.error_count, + tf.convert(Duration::from_secs(e.last_try_secs_ago)), + next_try + )); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_get_block_info(&self, hash: String) -> Result<(), Error> { + let info = self + .local_api_request(LocalGetBlockInfoRequest { block_hash: hash }) + .await?; + + println!("Block hash: {}", info.block_hash); + println!("Refcount: {}", info.refcount); + println!(); + + let mut table = vec!["Version\tBucket\tKey\tMPU\tDeleted".into()]; + let mut nondeleted_count = 0; + for ver in info.versions.iter() { + match &ver.backlink { + Some(BlockVersionBacklink::Object { bucket_id, key }) => { + table.push(format!( + "{:.16}\t{:.16}\t{}\t\t{:?}", + ver.version_id, bucket_id, key, ver.deleted + )); + } + Some(BlockVersionBacklink::Upload { + upload_id, + upload_deleted: _, + upload_garbage_collected: _, + bucket_id, + key, + }) => { + table.push(format!( + "{:.16}\t{:.16}\t{}\t{:.16}\t{:.16}", + ver.version_id, + bucket_id.as_deref().unwrap_or(""), + key.as_deref().unwrap_or(""), + upload_id, + ver.deleted + )); + } + None => { + table.push(format!("{:.16}\t\t\tyes", ver.version_id)); + } + } + if !ver.deleted { + nondeleted_count += 1; + } + } + format_table(table); + + if info.refcount != nondeleted_count { + println!(); + println!( + "Warning: refcount does not match number of non-deleted versions, you should try `garage repair block-rc`." + ); + } + + Ok(()) + } +} diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index b175ab38..462e5722 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -3,6 +3,7 @@ pub mod cluster; pub mod key; pub mod layout; +pub mod block; pub mod worker; use std::convert::TryFrom; @@ -41,6 +42,7 @@ impl Cli { Command::Bucket(bo) => self.cmd_bucket(bo).await, Command::Key(ko) => self.cmd_key(ko).await, Command::Worker(wo) => self.cmd_worker(wo).await, + Command::Block(bo) => self.cmd_block(bo).await, // TODO Command::Repair(ro) => cli_v1::cmd_admin( @@ -55,13 +57,6 @@ impl Cli { .await .ok_or_message("cli_v1") } - Command::Block(bo) => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::BlockOperation(bo), - ) - .await - .ok_or_message("cli_v1"), Command::Meta(mo) => cli_v1::cmd_admin( &self.admin_rpc_endpoint, self.rpc_host, diff --git a/src/garage/cli_v2/worker.rs b/src/garage/cli_v2/worker.rs index b94a4f68..9c248a39 100644 --- a/src/garage/cli_v2/worker.rs +++ b/src/garage/cli_v2/worker.rs @@ -1,4 +1,3 @@ -//use bytesize::ByteSize; use format_table::format_table; use garage_util::error::*; From b1629dd355806f40669d5d00db4e8e8f86a3fae2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 31 Jan 2025 17:19:26 +0100 Subject: [PATCH 045/192] cli_v2: implement RetryBlockResync and PurgeBlocks --- src/api/admin/api.rs | 36 +++++++++ src/api/admin/block.rs | 130 +++++++++++++++++++++++++++++++ src/api/admin/router_v2.rs | 2 + src/garage/admin/block.rs | 153 ------------------------------------- src/garage/admin/mod.rs | 4 - src/garage/cli_v2/block.rs | 52 +++++++++++-- 6 files changed, 212 insertions(+), 165 deletions(-) delete mode 100644 src/garage/admin/block.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 42872ad0..cde11bac 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -86,6 +86,8 @@ admin_endpoints![ // Block operations ListBlockErrors, GetBlockInfo, + RetryBlockResync, + PurgeBlocks, ]; local_admin_endpoints![ @@ -97,6 +99,8 @@ local_admin_endpoints![ // Block operations ListBlockErrors, GetBlockInfo, + RetryBlockResync, + PurgeBlocks, ]; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -765,3 +769,35 @@ pub enum BlockVersionBacklink { key: Option, }, } + +// ---- RetryBlockResync ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum LocalRetryBlockResyncRequest { + #[serde(rename_all = "camelCase")] + All { all: bool }, + #[serde(rename_all = "camelCase")] + Blocks { block_hashes: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalRetryBlockResyncResponse { + pub count: u64, +} + +// ---- PurgeBlocks ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalPurgeBlocksRequest(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalPurgeBlocksResponse { + pub blocks_purged: u64, + pub objects_deleted: u64, + pub uploads_deleted: u64, + pub versions_deleted: u64, +} diff --git a/src/api/admin/block.rs b/src/api/admin/block.rs index 157db5b5..cf143a71 100644 --- a/src/api/admin/block.rs +++ b/src/api/admin/block.rs @@ -9,6 +9,7 @@ use garage_util::time::now_msec; use garage_table::EmptyKey; use garage_model::garage::Garage; +use garage_model::s3::object_table::*; use garage_model::s3::version_table::*; use crate::admin::api::*; @@ -107,6 +108,89 @@ impl RequestHandler for LocalGetBlockInfoRequest { } } +#[async_trait] +impl RequestHandler for LocalRetryBlockResyncRequest { + type Response = LocalRetryBlockResyncResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + match self { + Self::All { all: true } => { + let blocks = garage.block_manager.list_resync_errors()?; + for b in blocks.iter() { + garage.block_manager.resync.clear_backoff(&b.hash)?; + } + Ok(LocalRetryBlockResyncResponse { + count: blocks.len() as u64, + }) + } + Self::All { all: false } => Err(Error::bad_request("nonsense")), + Self::Blocks { block_hashes } => { + for hash in block_hashes.iter() { + let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?; + let hash = Hash::try_from(&hash).ok_or_bad_request("invalid hash")?; + garage.block_manager.resync.clear_backoff(&hash)?; + } + Ok(LocalRetryBlockResyncResponse { + count: block_hashes.len() as u64, + }) + } + } + } +} + +#[async_trait] +impl RequestHandler for LocalPurgeBlocksRequest { + type Response = LocalPurgeBlocksResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut obj_dels = 0; + let mut mpu_dels = 0; + let mut ver_dels = 0; + + for hash in self.0.iter() { + let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?; + let hash = Hash::try_from(&hash).ok_or_bad_request("invalid hash")?; + let block_refs = garage + .block_ref_table + .get_range(&hash, None, None, 10000, Default::default()) + .await?; + + for br in block_refs { + if let Some(version) = garage.version_table.get(&br.version, &EmptyKey).await? { + handle_block_purge_version_backlink( + garage, + &version, + &mut obj_dels, + &mut mpu_dels, + ) + .await?; + + if !version.deleted.get() { + let deleted_version = Version::new(version.uuid, version.backlink, true); + garage.version_table.insert(&deleted_version).await?; + ver_dels += 1; + } + } + } + } + + Ok(LocalPurgeBlocksResponse { + blocks_purged: self.0.len() as u64, + versions_deleted: ver_dels, + objects_deleted: obj_dels, + uploads_deleted: mpu_dels, + }) + } +} + fn find_block_hash_by_prefix(garage: &Arc, prefix: &str) -> Result { if prefix.len() < 4 { return Err(Error::bad_request( @@ -147,3 +231,49 @@ fn find_block_hash_by_prefix(garage: &Arc, prefix: &str) -> Result, + version: &Version, + obj_dels: &mut u64, + mpu_dels: &mut u64, +) -> Result<(), Error> { + let (bucket_id, key, ov_id) = match &version.backlink { + VersionBacklink::Object { bucket_id, key } => (*bucket_id, key.clone(), version.uuid), + VersionBacklink::MultipartUpload { upload_id } => { + if let Some(mut mpu) = garage.mpu_table.get(upload_id, &EmptyKey).await? { + if !mpu.deleted.get() { + mpu.parts.clear(); + mpu.deleted.set(); + garage.mpu_table.insert(&mpu).await?; + *mpu_dels += 1; + } + (mpu.bucket_id, mpu.key.clone(), *upload_id) + } else { + return Ok(()); + } + } + }; + + if let Some(object) = garage.object_table.get(&bucket_id, &key).await? { + let ov = object.versions().iter().rev().find(|v| v.is_complete()); + if let Some(ov) = ov { + if ov.uuid == ov_id { + let del_uuid = gen_uuid(); + let deleted_object = Object::new( + bucket_id, + key, + vec![ObjectVersion { + uuid: del_uuid, + timestamp: ov.timestamp + 1, + state: ObjectVersionState::Complete(ObjectVersionData::DeleteMarker), + }], + ); + garage.object_table.insert(&deleted_object).await?; + *obj_dels += 1; + } + } + } + + Ok(()) +} diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 5c6cb29c..74822007 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -67,6 +67,8 @@ impl AdminApiRequest { // Block APIs GET ListBlockErrors (default::body, query::node), POST GetBlockInfo (body_field, query::node), + POST RetryBlockResync (body_field, query::node), + POST PurgeBlocks (body_field, query::node), ]); if let Some(message) = query.nonempty_message() { diff --git a/src/garage/admin/block.rs b/src/garage/admin/block.rs deleted file mode 100644 index 1138703a..00000000 --- a/src/garage/admin/block.rs +++ /dev/null @@ -1,153 +0,0 @@ -use garage_util::data::*; - -use garage_table::*; - -use garage_model::helper::error::{Error, OkOrBadRequest}; -use garage_model::s3::object_table::*; -use garage_model::s3::version_table::*; - -use crate::cli::*; - -use super::*; - -impl AdminRpcHandler { - pub(super) async fn handle_block_cmd(&self, cmd: &BlockOperation) -> Result { - match cmd { - BlockOperation::RetryNow { all, blocks } => { - self.handle_block_retry_now(*all, blocks).await - } - BlockOperation::Purge { yes, blocks } => self.handle_block_purge(*yes, blocks).await, - _ => unreachable!(), - } - } - - async fn handle_block_retry_now( - &self, - all: bool, - blocks: &[String], - ) -> Result { - if all { - if !blocks.is_empty() { - return Err(Error::BadRequest( - "--all was specified, cannot also specify blocks".into(), - )); - } - let blocks = self.garage.block_manager.list_resync_errors()?; - for b in blocks.iter() { - self.garage.block_manager.resync.clear_backoff(&b.hash)?; - } - Ok(AdminRpc::Ok(format!( - "{} blocks returned in queue for a retry now (check logs to see results)", - blocks.len() - ))) - } else { - for hash in blocks { - let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?; - let hash = Hash::try_from(&hash).ok_or_bad_request("invalid hash")?; - self.garage.block_manager.resync.clear_backoff(&hash)?; - } - Ok(AdminRpc::Ok(format!( - "{} blocks returned in queue for a retry now (check logs to see results)", - blocks.len() - ))) - } - } - - async fn handle_block_purge(&self, yes: bool, blocks: &[String]) -> Result { - if !yes { - return Err(Error::BadRequest( - "Pass the --yes flag to confirm block purge operation.".into(), - )); - } - - let mut obj_dels = 0; - let mut mpu_dels = 0; - let mut ver_dels = 0; - - for hash in blocks { - let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?; - let hash = Hash::try_from(&hash).ok_or_bad_request("invalid hash")?; - let block_refs = self - .garage - .block_ref_table - .get_range(&hash, None, None, 10000, Default::default()) - .await?; - - for br in block_refs { - if let Some(version) = self - .garage - .version_table - .get(&br.version, &EmptyKey) - .await? - { - self.handle_block_purge_version_backlink( - &version, - &mut obj_dels, - &mut mpu_dels, - ) - .await?; - - if !version.deleted.get() { - let deleted_version = Version::new(version.uuid, version.backlink, true); - self.garage.version_table.insert(&deleted_version).await?; - ver_dels += 1; - } - } - } - } - - Ok(AdminRpc::Ok(format!( - "Purged {} blocks, {} versions, {} objects, {} multipart uploads", - blocks.len(), - ver_dels, - obj_dels, - mpu_dels, - ))) - } - - async fn handle_block_purge_version_backlink( - &self, - version: &Version, - obj_dels: &mut usize, - mpu_dels: &mut usize, - ) -> Result<(), Error> { - let (bucket_id, key, ov_id) = match &version.backlink { - VersionBacklink::Object { bucket_id, key } => (*bucket_id, key.clone(), version.uuid), - VersionBacklink::MultipartUpload { upload_id } => { - if let Some(mut mpu) = self.garage.mpu_table.get(upload_id, &EmptyKey).await? { - if !mpu.deleted.get() { - mpu.parts.clear(); - mpu.deleted.set(); - self.garage.mpu_table.insert(&mpu).await?; - *mpu_dels += 1; - } - (mpu.bucket_id, mpu.key.clone(), *upload_id) - } else { - return Ok(()); - } - } - }; - - if let Some(object) = self.garage.object_table.get(&bucket_id, &key).await? { - let ov = object.versions().iter().rev().find(|v| v.is_complete()); - if let Some(ov) = ov { - if ov.uuid == ov_id { - let del_uuid = gen_uuid(); - let deleted_object = Object::new( - bucket_id, - key, - vec![ObjectVersion { - uuid: del_uuid, - timestamp: ov.timestamp + 1, - state: ObjectVersionState::Complete(ObjectVersionData::DeleteMarker), - }], - ); - self.garage.object_table.insert(&deleted_object).await?; - *obj_dels += 1; - } - } - } - - Ok(()) - } -} diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 1aa9482c..4f734b1a 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -1,5 +1,3 @@ -mod block; - use std::collections::HashMap; use std::fmt::Write; use std::sync::Arc; @@ -36,7 +34,6 @@ pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; pub enum AdminRpc { LaunchRepair(RepairOpt), Stats(StatsOpt), - BlockOperation(BlockOperation), MetaOperation(MetaOperation), // Replies @@ -371,7 +368,6 @@ impl EndpointHandler for AdminRpcHandler { match message { AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, AdminRpc::Stats(opt) => self.handle_stats(opt.clone()).await, - AdminRpc::BlockOperation(bo) => self.handle_block_cmd(bo).await, AdminRpc::MetaOperation(mo) => self.handle_meta_cmd(mo).await, m => Err(GarageError::unexpected_rpc_message(m).into()), } diff --git a/src/garage/cli_v2/block.rs b/src/garage/cli_v2/block.rs index ff3c79e9..7d4595eb 100644 --- a/src/garage/cli_v2/block.rs +++ b/src/garage/cli_v2/block.rs @@ -13,14 +13,8 @@ impl Cli { match cmd { BlockOperation::ListErrors => self.cmd_list_block_errors().await, BlockOperation::Info { hash } => self.cmd_get_block_info(hash).await, - - bo => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::BlockOperation(bo), - ) - .await - .ok_or_message("cli_v1"), + BlockOperation::RetryNow { all, blocks } => self.cmd_block_retry_now(all, blocks).await, + BlockOperation::Purge { yes, blocks } => self.cmd_block_purge(yes, blocks).await, } } @@ -106,4 +100,46 @@ impl Cli { Ok(()) } + + pub async fn cmd_block_retry_now(&self, all: bool, blocks: Vec) -> Result<(), Error> { + let req = match (all, blocks.len()) { + (true, 0) => LocalRetryBlockResyncRequest::All { all: true }, + (false, n) if n > 0 => LocalRetryBlockResyncRequest::Blocks { + block_hashes: blocks, + }, + _ => { + return Err(Error::Message( + "Please specify block hashes or --all (not both)".into(), + )) + } + }; + + let res = self.local_api_request(req).await?; + + println!( + "{} blocks returned in queue for a retry now (check logs to see results)", + res.count + ); + + Ok(()) + } + + pub async fn cmd_block_purge(&self, yes: bool, blocks: Vec) -> Result<(), Error> { + if !yes { + return Err(Error::Message( + "Pass the --yes flag to confirm block purge operation.".into(), + )); + } + + let res = self + .local_api_request(LocalPurgeBlocksRequest(blocks)) + .await?; + + println!( + "Purged {} blocks: deleted {} versions, {} objects, {} multipart uploads", + res.blocks_purged, res.versions_deleted, res.objects_deleted, res.uploads_deleted, + ); + + Ok(()) + } } From 6a1079c4129157ae6c6e2a94b10d9c2b8f91c5b6 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 31 Jan 2025 17:51:50 +0100 Subject: [PATCH 046/192] admin api: impl RequestHandler for MetricsRequest --- src/api/admin/api_server.rs | 36 +----------- src/api/admin/block.rs | 9 +-- src/api/admin/special.rs | 110 ++++++++++++++++++++++++------------ src/garage/cli_v2/block.rs | 2 +- 4 files changed, 84 insertions(+), 73 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index e865d199..ecc538e4 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -5,7 +5,7 @@ use argon2::password_hash::PasswordHash; use async_trait::async_trait; use http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION}; -use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; +use hyper::{body::Incoming as IncomingBody, Request, Response}; use serde::{Deserialize, Serialize}; use tokio::sync::watch; @@ -13,8 +13,6 @@ use opentelemetry::trace::SpanRef; #[cfg(feature = "metrics")] use opentelemetry_prometheus::PrometheusExporter; -#[cfg(feature = "metrics")] -use prometheus::{Encoder, TextEncoder}; use garage_model::garage::Garage; use garage_rpc::{Endpoint as RpcEndpoint, *}; @@ -100,7 +98,7 @@ pub type ResBody = BoxBody; pub struct AdminApiServer { garage: Arc, #[cfg(feature = "metrics")] - exporter: PrometheusExporter, + pub(crate) exporter: PrometheusExporter, metrics_token: Option, admin_token: Option, pub(crate) background: Arc, @@ -148,34 +146,6 @@ impl AdminApiServer { .run_server(bind_addr, Some(0o220), must_exit) .await } - - fn handle_metrics(&self) -> Result, Error> { - #[cfg(feature = "metrics")] - { - use opentelemetry::trace::Tracer; - - let mut buffer = vec![]; - let encoder = TextEncoder::new(); - - let tracer = opentelemetry::global::tracer("garage"); - let metric_families = tracer.in_span("admin/gather_metrics", |_| { - self.exporter.registry().gather() - }); - - encoder - .encode(&metric_families, &mut buffer) - .ok_or_internal_error("Could not serialize metrics")?; - - Ok(Response::builder() - .status(StatusCode::OK) - .header(http::header::CONTENT_TYPE, encoder.format_type()) - .body(bytes_body(buffer.into()))?) - } - #[cfg(not(feature = "metrics"))] - Err(Error::bad_request( - "Garage was built without the metrics feature".to_string(), - )) - } } #[async_trait] @@ -246,7 +216,7 @@ impl AdminApiServer { AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await, AdminApiRequest::CheckDomain(req) => req.handle(&self.garage, &self).await, AdminApiRequest::Health(req) => req.handle(&self.garage, &self).await, - AdminApiRequest::Metrics(_req) => self.handle_metrics(), + AdminApiRequest::Metrics(req) => req.handle(&self.garage, &self).await, req => { let res = req.handle(&self.garage, &self).await?; let mut res = json_ok_response(&res)?; diff --git a/src/api/admin/block.rs b/src/api/admin/block.rs index cf143a71..8f0e63eb 100644 --- a/src/api/admin/block.rs +++ b/src/api/admin/block.rs @@ -12,10 +12,11 @@ use garage_model::garage::Garage; use garage_model::s3::object_table::*; use garage_model::s3::version_table::*; -use crate::admin::api::*; -use crate::admin::error::*; -use crate::admin::{Admin, RequestHandler}; -use crate::common_error::CommonErrorDerivative; +use garage_api_common::common_error::CommonErrorDerivative; + +use crate::api::*; +use crate::error::*; +use crate::{Admin, RequestHandler}; #[async_trait] impl RequestHandler for LocalListBlockErrorsRequest { diff --git a/src/api/admin/special.rs b/src/api/admin/special.rs index 4717238d..79f1f4d7 100644 --- a/src/api/admin/special.rs +++ b/src/api/admin/special.rs @@ -7,12 +7,15 @@ use http::header::{ }; use hyper::{Response, StatusCode}; +#[cfg(feature = "metrics")] +use prometheus::{Encoder, TextEncoder}; + use garage_model::garage::Garage; use garage_rpc::system::ClusterHealthStatus; use garage_api_common::helpers::*; -use crate::api::{CheckDomainRequest, HealthRequest, OptionsRequest}; +use crate::api::{CheckDomainRequest, HealthRequest, MetricsRequest, OptionsRequest}; use crate::api_server::ResBody; use crate::error::*; use crate::{Admin, RequestHandler}; @@ -36,6 +39,77 @@ impl RequestHandler for OptionsRequest { } } +#[async_trait] +impl RequestHandler for MetricsRequest { + type Response = Response; + + async fn handle( + self, + _garage: &Arc, + admin: &Admin, + ) -> Result, Error> { + #[cfg(feature = "metrics")] + { + use opentelemetry::trace::Tracer; + + let mut buffer = vec![]; + let encoder = TextEncoder::new(); + + let tracer = opentelemetry::global::tracer("garage"); + let metric_families = tracer.in_span("admin/gather_metrics", |_| { + admin.exporter.registry().gather() + }); + + encoder + .encode(&metric_families, &mut buffer) + .ok_or_internal_error("Could not serialize metrics")?; + + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, encoder.format_type()) + .body(bytes_body(buffer.into()))?) + } + #[cfg(not(feature = "metrics"))] + Err(Error::bad_request( + "Garage was built without the metrics feature".to_string(), + )) + } +} + +#[async_trait] +impl RequestHandler for HealthRequest { + type Response = Response; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result, Error> { + let health = garage.system.health(); + + let (status, status_str) = match health.status { + ClusterHealthStatus::Healthy => (StatusCode::OK, "Garage is fully operational"), + ClusterHealthStatus::Degraded => ( + StatusCode::OK, + "Garage is operational but some storage nodes are unavailable", + ), + ClusterHealthStatus::Unavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "Quorum is not available for some/all partitions, reads and writes will fail", + ), + }; + let status_str = format!( + "{}\nConsult the full health check API endpoint at /v2/GetClusterHealth for more details\n", + status_str + ); + + Ok(Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(string_body(status_str))?) + } +} + #[async_trait] impl RequestHandler for CheckDomainRequest { type Response = Response; @@ -109,37 +183,3 @@ async fn check_domain(garage: &Arc, domain: &str) -> Result None => Ok(false), } } - -#[async_trait] -impl RequestHandler for HealthRequest { - type Response = Response; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result, Error> { - let health = garage.system.health(); - - let (status, status_str) = match health.status { - ClusterHealthStatus::Healthy => (StatusCode::OK, "Garage is fully operational"), - ClusterHealthStatus::Degraded => ( - StatusCode::OK, - "Garage is operational but some storage nodes are unavailable", - ), - ClusterHealthStatus::Unavailable => ( - StatusCode::SERVICE_UNAVAILABLE, - "Quorum is not available for some/all partitions, reads and writes will fail", - ), - }; - let status_str = format!( - "{}\nConsult the full health check API endpoint at /v2/GetClusterHealth for more details\n", - status_str - ); - - Ok(Response::builder() - .status(status) - .header(http::header::CONTENT_TYPE, "text/plain") - .body(string_body(status_str))?) - } -} diff --git a/src/garage/cli_v2/block.rs b/src/garage/cli_v2/block.rs index 7d4595eb..bfc0db4a 100644 --- a/src/garage/cli_v2/block.rs +++ b/src/garage/cli_v2/block.rs @@ -3,7 +3,7 @@ use format_table::format_table; use garage_util::error::*; -use garage_api::admin::api::*; +use garage_api_admin::api::*; use crate::cli::structs::*; use crate::cli_v2::*; From 97be7b38fa3bd3172895f6ab44157e5236d65cd6 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 1 Feb 2025 19:35:00 +0100 Subject: [PATCH 047/192] admin api: reorder things --- src/api/admin/api_server.rs | 66 ++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index ecc538e4..1ab81be3 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -110,8 +110,6 @@ pub enum HttpEndpoint { New(String), } -struct ArcAdminApiServer(Arc); - impl AdminApiServer { pub fn new( garage: Arc, @@ -146,39 +144,7 @@ impl AdminApiServer { .run_server(bind_addr, Some(0o220), must_exit) .await } -} -#[async_trait] -impl ApiHandler for ArcAdminApiServer { - const API_NAME: &'static str = "admin"; - const API_NAME_DISPLAY: &'static str = "Admin"; - - type Endpoint = HttpEndpoint; - type Error = Error; - - fn parse_endpoint(&self, req: &Request) -> Result { - if req.uri().path().starts_with("/v0/") { - let endpoint_v0 = router_v0::Endpoint::from_request(req)?; - let endpoint_v1 = router_v1::Endpoint::from_v0(endpoint_v0)?; - Ok(HttpEndpoint::Old(endpoint_v1)) - } else if req.uri().path().starts_with("/v1/") { - let endpoint_v1 = router_v1::Endpoint::from_request(req)?; - Ok(HttpEndpoint::Old(endpoint_v1)) - } else { - Ok(HttpEndpoint::New(req.uri().path().to_string())) - } - } - - async fn handle( - &self, - req: Request, - endpoint: HttpEndpoint, - ) -> Result, Error> { - self.0.handle_http_api(req, endpoint).await - } -} - -impl AdminApiServer { async fn handle_http_api( &self, req: Request, @@ -228,6 +194,38 @@ impl AdminApiServer { } } +struct ArcAdminApiServer(Arc); + +#[async_trait] +impl ApiHandler for ArcAdminApiServer { + const API_NAME: &'static str = "admin"; + const API_NAME_DISPLAY: &'static str = "Admin"; + + type Endpoint = HttpEndpoint; + type Error = Error; + + fn parse_endpoint(&self, req: &Request) -> Result { + if req.uri().path().starts_with("/v0/") { + let endpoint_v0 = router_v0::Endpoint::from_request(req)?; + let endpoint_v1 = router_v1::Endpoint::from_v0(endpoint_v0)?; + Ok(HttpEndpoint::Old(endpoint_v1)) + } else if req.uri().path().starts_with("/v1/") { + let endpoint_v1 = router_v1::Endpoint::from_request(req)?; + Ok(HttpEndpoint::Old(endpoint_v1)) + } else { + Ok(HttpEndpoint::New(req.uri().path().to_string())) + } + } + + async fn handle( + &self, + req: Request, + endpoint: HttpEndpoint, + ) -> Result, Error> { + self.0.handle_http_api(req, endpoint).await + } +} + impl ApiEndpoint for HttpEndpoint { fn name(&self) -> Cow<'static, str> { match self { From 9f468b4439bdd5e2e67a6215f941556310877155 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 5 Feb 2025 14:22:10 +0100 Subject: [PATCH 048/192] cli_v2: implement CreateMetadataSnapshot --- src/api/admin/api.rs | 17 +++++++++++++++ src/api/admin/lib.rs | 1 + src/api/admin/node.rs | 23 ++++++++++++++++++++ src/api/admin/router_v2.rs | 2 ++ src/garage/admin/mod.rs | 43 -------------------------------------- src/garage/cli/cmd.rs | 18 ---------------- src/garage/cli/layout.rs | 13 ++++++++++++ src/garage/cli_v2/mod.rs | 9 ++------ src/garage/cli_v2/node.rs | 36 +++++++++++++++++++++++++++++++ 9 files changed, 94 insertions(+), 68 deletions(-) create mode 100644 src/api/admin/node.rs create mode 100644 src/garage/cli_v2/node.rs diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index cde11bac..3f041208 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -77,6 +77,9 @@ admin_endpoints![ AddBucketAlias, RemoveBucketAlias, + // Node operations + CreateMetadataSnapshot, + // Worker operations ListWorkers, GetWorkerInfo, @@ -91,6 +94,8 @@ admin_endpoints![ ]; local_admin_endpoints![ + // Node operations + CreateMetadataSnapshot, // Background workers ListWorkers, GetWorkerInfo, @@ -623,6 +628,18 @@ pub struct RemoveBucketAliasRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); +// ********************************************** +// Node operations +// ********************************************** + +// ---- CreateMetadataSnapshot ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LocalCreateMetadataSnapshotRequest; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalCreateMetadataSnapshotResponse; + // ********************************************** // Worker operations // ********************************************** diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index e7ee37af..cc673eef 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -16,6 +16,7 @@ mod key; mod special; mod block; +mod node; mod worker; use std::sync::Arc; diff --git a/src/api/admin/node.rs b/src/api/admin/node.rs new file mode 100644 index 00000000..8c79acfd --- /dev/null +++ b/src/api/admin/node.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use garage_model::garage::Garage; + +use crate::api::*; +use crate::error::Error; +use crate::{Admin, RequestHandler}; + +#[async_trait] +impl RequestHandler for LocalCreateMetadataSnapshotRequest { + type Response = LocalCreateMetadataSnapshotResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + garage_model::snapshot::async_snapshot_metadata(garage).await?; + Ok(LocalCreateMetadataSnapshotResponse) + } +} diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 74822007..dac6c5f9 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -59,6 +59,8 @@ impl AdminApiRequest { // Bucket aliases POST AddBucketAlias (body), POST RemoveBucketAlias (body), + // Node APIs + POST CreateMetadataSnapshot (default::body, query::node), // Worker APIs POST ListWorkers (body_field, query::node), POST GetWorkerInfo (body_field, query::node), diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 4f734b1a..87724559 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -20,10 +20,6 @@ use garage_rpc::*; use garage_model::garage::Garage; use garage_model::helper::error::Error; -use garage_api_admin::api::{AdminApiRequest, TaggedAdminApiResponse}; -use garage_api_admin::RequestHandler as AdminApiEndpoint; -use garage_api_common::generic_server::ApiError; - use crate::cli::*; use crate::repair::online::launch_online_repair; @@ -34,7 +30,6 @@ pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; pub enum AdminRpc { LaunchRepair(RepairOpt), Stats(StatsOpt), - MetaOperation(MetaOperation), // Replies Ok(String), @@ -319,43 +314,6 @@ impl AdminRpcHandler { t.data.gc_todo_len()? )) } - - // ================ META DB COMMANDS ==================== - - async fn handle_meta_cmd(self: &Arc, mo: &MetaOperation) -> Result { - match mo { - MetaOperation::Snapshot { all: true } => { - let to = self.garage.system.cluster_layout().all_nodes().to_vec(); - - let resps = futures::future::join_all(to.iter().map(|to| async move { - let to = (*to).into(); - self.endpoint - .call( - &to, - AdminRpc::MetaOperation(MetaOperation::Snapshot { all: false }), - PRIO_NORMAL, - ) - .await - })) - .await; - - let mut ret = vec![]; - for (to, resp) in to.iter().zip(resps.iter()) { - let res_str = match resp { - Ok(_) => "ok".to_string(), - Err(e) => format!("error: {}", e), - }; - ret.push(format!("{:?}\t{}", to, res_str)); - } - - Ok(AdminRpc::Ok(format_table_to_string(ret))) - } - MetaOperation::Snapshot { all: false } => { - garage_model::snapshot::async_snapshot_metadata(&self.garage).await?; - Ok(AdminRpc::Ok("Snapshot has been saved.".into())) - } - } - } } #[async_trait] @@ -368,7 +326,6 @@ impl EndpointHandler for AdminRpcHandler { match message { AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, AdminRpc::Stats(opt) => self.handle_stats(opt.clone()).await, - AdminRpc::MetaOperation(mo) => self.handle_meta_cmd(mo).await, m => Err(GarageError::unexpected_rpc_message(m).into()), } } diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs index e5af461c..1a9c7841 100644 --- a/src/garage/cli/cmd.rs +++ b/src/garage/cli/cmd.rs @@ -1,6 +1,3 @@ -use garage_util::error::*; - -use garage_rpc::system::*; use garage_rpc::*; use garage_model::helper::error::Error as HelperError; @@ -22,18 +19,3 @@ pub async fn cmd_admin( } Ok(()) } - -// ---- utility ---- - -pub async fn fetch_status( - rpc_cli: &Endpoint, - rpc_host: NodeID, -) -> Result, Error> { - match rpc_cli - .call(&rpc_host, SystemRpc::GetKnownNodes, PRIO_NORMAL) - .await?? - { - SystemRpc::ReturnKnownNodes(nodes) => Ok(nodes), - resp => Err(Error::unexpected_rpc_message(resp)), - } -} diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index bb81d144..15040aaa 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -260,6 +260,19 @@ pub async fn cmd_layout_skip_dead_nodes( // --- utility --- +pub async fn fetch_status( + rpc_cli: &Endpoint, + rpc_host: NodeID, +) -> Result, Error> { + match rpc_cli + .call(&rpc_host, SystemRpc::GetKnownNodes, PRIO_NORMAL) + .await?? + { + SystemRpc::ReturnKnownNodes(nodes) => Ok(nodes), + resp => Err(Error::unexpected_rpc_message(resp)), + } +} + pub async fn fetch_layout( rpc_cli: &Endpoint, rpc_host: NodeID, diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 462e5722..0de4ead8 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -4,6 +4,7 @@ pub mod key; pub mod layout; pub mod block; +pub mod node; pub mod worker; use std::convert::TryFrom; @@ -43,6 +44,7 @@ impl Cli { Command::Key(ko) => self.cmd_key(ko).await, Command::Worker(wo) => self.cmd_worker(wo).await, Command::Block(bo) => self.cmd_block(bo).await, + Command::Meta(mo) => self.cmd_meta(mo).await, // TODO Command::Repair(ro) => cli_v1::cmd_admin( @@ -57,13 +59,6 @@ impl Cli { .await .ok_or_message("cli_v1") } - Command::Meta(mo) => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::MetaOperation(mo), - ) - .await - .ok_or_message("cli_v1"), _ => unreachable!(), } diff --git a/src/garage/cli_v2/node.rs b/src/garage/cli_v2/node.rs new file mode 100644 index 00000000..c5f28300 --- /dev/null +++ b/src/garage/cli_v2/node.rs @@ -0,0 +1,36 @@ +use format_table::format_table; + +use garage_util::error::*; + +use garage_api_admin::api::*; + +use crate::cli::structs::*; +use crate::cli_v2::*; + +impl Cli { + pub async fn cmd_meta(&self, cmd: MetaOperation) -> Result<(), Error> { + let MetaOperation::Snapshot { all } = cmd; + + let res = self + .api_request(CreateMetadataSnapshotRequest { + node: if all { + "*".to_string() + } else { + hex::encode(self.rpc_host) + }, + body: LocalCreateMetadataSnapshotRequest, + }) + .await?; + + let mut table = vec![]; + for (node, err) in res.error.iter() { + table.push(format!("{:.16}\tError: {}", node, err)); + } + for (node, _) in res.success.iter() { + table.push(format!("{:.16}\tOk", node)); + } + format_table(table); + + Ok(()) + } +} From 406b6da1634a38c1b8176ff468d964e42ce5ce5d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 5 Feb 2025 15:06:10 +0100 Subject: [PATCH 049/192] cli_v2: implement Get{Node,Cluster}Statistics --- Cargo.lock | 2 + src/api/admin/Cargo.toml | 2 + src/api/admin/api.rs | 23 ++++ src/api/admin/node.rs | 198 ++++++++++++++++++++++++++++++++ src/api/admin/router_v2.rs | 2 + src/garage/admin/mod.rs | 224 ------------------------------------- src/garage/cli_v2/mod.rs | 6 +- src/garage/cli_v2/node.rs | 31 +++++ 8 files changed, 259 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 659e2fe7..9ba0d553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1277,7 +1277,9 @@ version = "1.0.1" dependencies = [ "argon2", "async-trait", + "bytesize", "err-derive", + "format_table", "futures", "garage_api_common", "garage_model", diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 94a321a6..7b1ad2f0 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -14,6 +14,7 @@ path = "lib.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +format_table.workspace = true garage_model.workspace = true garage_table.workspace = true garage_util.workspace = true @@ -22,6 +23,7 @@ garage_api_common.workspace = true argon2.workspace = true async-trait.workspace = true +bytesize.workspace = true err-derive.workspace = true hex.workspace = true paste.workspace = true diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 3f041208..4caae02c 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -79,6 +79,8 @@ admin_endpoints![ // Node operations CreateMetadataSnapshot, + GetNodeStatistics, + GetClusterStatistics, // Worker operations ListWorkers, @@ -96,6 +98,7 @@ admin_endpoints![ local_admin_endpoints![ // Node operations CreateMetadataSnapshot, + GetNodeStatistics, // Background workers ListWorkers, GetWorkerInfo, @@ -640,6 +643,26 @@ pub struct LocalCreateMetadataSnapshotRequest; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalCreateMetadataSnapshotResponse; +// ---- GetNodeStatistics ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LocalGetNodeStatisticsRequest; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalGetNodeStatisticsResponse { + pub freeform: String, +} + +// ---- GetClusterStatistics ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GetClusterStatisticsRequest; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetClusterStatisticsResponse { + pub freeform: String, +} + // ********************************************** // Worker operations // ********************************************** diff --git a/src/api/admin/node.rs b/src/api/admin/node.rs index 8c79acfd..870db9fb 100644 --- a/src/api/admin/node.rs +++ b/src/api/admin/node.rs @@ -1,7 +1,19 @@ +use std::collections::HashMap; +use std::fmt::Write; use std::sync::Arc; use async_trait::async_trait; +use format_table::format_table_to_string; + +use garage_util::data::*; +use garage_util::error::Error as GarageError; + +use garage_table::replication::*; +use garage_table::*; + +use garage_rpc::layout::PARTITION_BITS; + use garage_model::garage::Garage; use crate::api::*; @@ -21,3 +33,189 @@ impl RequestHandler for LocalCreateMetadataSnapshotRequest { Ok(LocalCreateMetadataSnapshotResponse) } } + +#[async_trait] +impl RequestHandler for LocalGetNodeStatisticsRequest { + type Response = LocalGetNodeStatisticsResponse; + + // FIXME: return this as a JSON struct instead of text + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut ret = String::new(); + writeln!( + &mut ret, + "Garage version: {} [features: {}]\nRust compiler version: {}", + garage_util::version::garage_version(), + garage_util::version::garage_features() + .map(|list| list.join(", ")) + .unwrap_or_else(|| "(unknown)".into()), + garage_util::version::rust_version(), + ) + .unwrap(); + + writeln!(&mut ret, "\nDatabase engine: {}", garage.db.engine()).unwrap(); + + // Gather table statistics + let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tGcTodo".into()]; + table.push(gather_table_stats(&garage.bucket_table)?); + table.push(gather_table_stats(&garage.key_table)?); + table.push(gather_table_stats(&garage.object_table)?); + table.push(gather_table_stats(&garage.version_table)?); + table.push(gather_table_stats(&garage.block_ref_table)?); + write!( + &mut ret, + "\nTable stats:\n{}", + format_table_to_string(table) + ) + .unwrap(); + + // Gather block manager statistics + writeln!(&mut ret, "\nBlock manager stats:").unwrap(); + let rc_len = garage.block_manager.rc_len()?.to_string(); + + writeln!( + &mut ret, + " number of RC entries (~= number of blocks): {}", + rc_len + ) + .unwrap(); + writeln!( + &mut ret, + " resync queue length: {}", + garage.block_manager.resync.queue_len()? + ) + .unwrap(); + writeln!( + &mut ret, + " blocks with resync errors: {}", + garage.block_manager.resync.errors_len()? + ) + .unwrap(); + + Ok(LocalGetNodeStatisticsResponse { freeform: ret }) + } +} + +#[async_trait] +impl RequestHandler for GetClusterStatisticsRequest { + type Response = GetClusterStatisticsResponse; + + // FIXME: return this as a JSON struct instead of text + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut ret = String::new(); + + // Gather storage node and free space statistics for current nodes + let layout = &garage.system.cluster_layout(); + let mut node_partition_count = HashMap::::new(); + for short_id in layout.current().ring_assignment_data.iter() { + let id = layout.current().node_id_vec[*short_id as usize]; + *node_partition_count.entry(id).or_default() += 1; + } + let node_info = garage + .system + .get_known_nodes() + .into_iter() + .map(|n| (n.id, n)) + .collect::>(); + + let mut table = vec![" ID\tHostname\tZone\tCapacity\tPart.\tDataAvail\tMetaAvail".into()]; + for (id, parts) in node_partition_count.iter() { + let info = node_info.get(id); + let status = info.map(|x| &x.status); + let role = layout.current().roles.get(id).and_then(|x| x.0.as_ref()); + let hostname = status.and_then(|x| x.hostname.as_deref()).unwrap_or("?"); + let zone = role.map(|x| x.zone.as_str()).unwrap_or("?"); + let capacity = role + .map(|x| x.capacity_string()) + .unwrap_or_else(|| "?".into()); + let avail_str = |x| match x { + Some((avail, total)) => { + let pct = (avail as f64) / (total as f64) * 100.; + let avail = bytesize::ByteSize::b(avail); + let total = bytesize::ByteSize::b(total); + format!("{}/{} ({:.1}%)", avail, total, pct) + } + None => "?".into(), + }; + let data_avail = avail_str(status.and_then(|x| x.data_disk_avail)); + let meta_avail = avail_str(status.and_then(|x| x.meta_disk_avail)); + table.push(format!( + " {:?}\t{}\t{}\t{}\t{}\t{}\t{}", + id, hostname, zone, capacity, parts, data_avail, meta_avail + )); + } + write!( + &mut ret, + "Storage nodes:\n{}", + format_table_to_string(table) + ) + .unwrap(); + + let meta_part_avail = node_partition_count + .iter() + .filter_map(|(id, parts)| { + node_info + .get(id) + .and_then(|x| x.status.meta_disk_avail) + .map(|c| c.0 / *parts) + }) + .collect::>(); + let data_part_avail = node_partition_count + .iter() + .filter_map(|(id, parts)| { + node_info + .get(id) + .and_then(|x| x.status.data_disk_avail) + .map(|c| c.0 / *parts) + }) + .collect::>(); + if !meta_part_avail.is_empty() && !data_part_avail.is_empty() { + let meta_avail = + bytesize::ByteSize(meta_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); + let data_avail = + bytesize::ByteSize(data_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); + writeln!( + &mut ret, + "\nEstimated available storage space cluster-wide (might be lower in practice):" + ) + .unwrap(); + if meta_part_avail.len() < node_partition_count.len() + || data_part_avail.len() < node_partition_count.len() + { + writeln!(&mut ret, " data: < {}", data_avail).unwrap(); + writeln!(&mut ret, " metadata: < {}", meta_avail).unwrap(); + writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap(); + } else { + writeln!(&mut ret, " data: {}", data_avail).unwrap(); + writeln!(&mut ret, " metadata: {}", meta_avail).unwrap(); + } + } + + Ok(GetClusterStatisticsResponse { freeform: ret }) + } +} + +fn gather_table_stats(t: &Arc>) -> Result +where + F: TableSchema + 'static, + R: TableReplication + 'static, +{ + let data_len = t.data.store.len().map_err(GarageError::from)?.to_string(); + let mkl_len = t.merkle_updater.merkle_tree_len()?.to_string(); + + Ok(format!( + " {}\t{}\t{}\t{}\t{}", + F::TABLE_NAME, + data_len, + mkl_len, + t.merkle_updater.todo_len()?, + t.data.gc_todo_len()? + )) +} diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index dac6c5f9..a0f415c2 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -61,6 +61,8 @@ impl AdminApiRequest { POST RemoveBucketAlias (body), // Node APIs POST CreateMetadataSnapshot (default::body, query::node), + GET GetNodeStatistics (default::body, query::node), + GET GetClusterStatistics (), // Worker APIs POST ListWorkers (body_field, query::node), POST GetWorkerInfo (body_field, query::node), diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 87724559..c4ab2810 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -1,20 +1,11 @@ -use std::collections::HashMap; -use std::fmt::Write; use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use format_table::format_table_to_string; - use garage_util::background::BackgroundRunner; -use garage_util::data::*; use garage_util::error::Error as GarageError; -use garage_table::replication::*; -use garage_table::*; - -use garage_rpc::layout::PARTITION_BITS; use garage_rpc::*; use garage_model::garage::Garage; @@ -29,7 +20,6 @@ pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; #[allow(clippy::large_enum_variant)] pub enum AdminRpc { LaunchRepair(RepairOpt), - Stats(StatsOpt), // Replies Ok(String), @@ -101,219 +91,6 @@ impl AdminRpcHandler { ))) } } - - // ================ STATS COMMANDS ==================== - - async fn handle_stats(&self, opt: StatsOpt) -> Result { - if opt.all_nodes { - let mut ret = String::new(); - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - - for node in all_nodes.iter() { - let mut opt = opt.clone(); - opt.all_nodes = false; - opt.skip_global = true; - - writeln!(&mut ret, "\n======================").unwrap(); - writeln!(&mut ret, "Stats for node {:?}:", node).unwrap(); - - let node_id = (*node).into(); - match self - .endpoint - .call(&node_id, AdminRpc::Stats(opt), PRIO_NORMAL) - .await - { - Ok(Ok(AdminRpc::Ok(s))) => writeln!(&mut ret, "{}", s).unwrap(), - Ok(Ok(x)) => writeln!(&mut ret, "Bad answer: {:?}", x).unwrap(), - Ok(Err(e)) => writeln!(&mut ret, "Remote error: {}", e).unwrap(), - Err(e) => writeln!(&mut ret, "Network error: {}", e).unwrap(), - } - } - - writeln!(&mut ret, "\n======================").unwrap(); - write!( - &mut ret, - "Cluster statistics:\n\n{}", - self.gather_cluster_stats() - ) - .unwrap(); - - Ok(AdminRpc::Ok(ret)) - } else { - Ok(AdminRpc::Ok(self.gather_stats_local(opt)?)) - } - } - - fn gather_stats_local(&self, opt: StatsOpt) -> Result { - let mut ret = String::new(); - writeln!( - &mut ret, - "\nGarage version: {} [features: {}]\nRust compiler version: {}", - garage_util::version::garage_version(), - garage_util::version::garage_features() - .map(|list| list.join(", ")) - .unwrap_or_else(|| "(unknown)".into()), - garage_util::version::rust_version(), - ) - .unwrap(); - - writeln!(&mut ret, "\nDatabase engine: {}", self.garage.db.engine()).unwrap(); - - // Gather table statistics - let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tGcTodo".into()]; - table.push(self.gather_table_stats(&self.garage.bucket_table)?); - table.push(self.gather_table_stats(&self.garage.key_table)?); - table.push(self.gather_table_stats(&self.garage.object_table)?); - table.push(self.gather_table_stats(&self.garage.version_table)?); - table.push(self.gather_table_stats(&self.garage.block_ref_table)?); - write!( - &mut ret, - "\nTable stats:\n{}", - format_table_to_string(table) - ) - .unwrap(); - - // Gather block manager statistics - writeln!(&mut ret, "\nBlock manager stats:").unwrap(); - let rc_len = self.garage.block_manager.rc_len()?.to_string(); - - writeln!( - &mut ret, - " number of RC entries (~= number of blocks): {}", - rc_len - ) - .unwrap(); - writeln!( - &mut ret, - " resync queue length: {}", - self.garage.block_manager.resync.queue_len()? - ) - .unwrap(); - writeln!( - &mut ret, - " blocks with resync errors: {}", - self.garage.block_manager.resync.errors_len()? - ) - .unwrap(); - - if !opt.skip_global { - write!(&mut ret, "\n{}", self.gather_cluster_stats()).unwrap(); - } - - Ok(ret) - } - - fn gather_cluster_stats(&self) -> String { - let mut ret = String::new(); - - // Gather storage node and free space statistics for current nodes - let layout = &self.garage.system.cluster_layout(); - let mut node_partition_count = HashMap::::new(); - for short_id in layout.current().ring_assignment_data.iter() { - let id = layout.current().node_id_vec[*short_id as usize]; - *node_partition_count.entry(id).or_default() += 1; - } - let node_info = self - .garage - .system - .get_known_nodes() - .into_iter() - .map(|n| (n.id, n)) - .collect::>(); - - let mut table = vec![" ID\tHostname\tZone\tCapacity\tPart.\tDataAvail\tMetaAvail".into()]; - for (id, parts) in node_partition_count.iter() { - let info = node_info.get(id); - let status = info.map(|x| &x.status); - let role = layout.current().roles.get(id).and_then(|x| x.0.as_ref()); - let hostname = status.and_then(|x| x.hostname.as_deref()).unwrap_or("?"); - let zone = role.map(|x| x.zone.as_str()).unwrap_or("?"); - let capacity = role - .map(|x| x.capacity_string()) - .unwrap_or_else(|| "?".into()); - let avail_str = |x| match x { - Some((avail, total)) => { - let pct = (avail as f64) / (total as f64) * 100.; - let avail = bytesize::ByteSize::b(avail); - let total = bytesize::ByteSize::b(total); - format!("{}/{} ({:.1}%)", avail, total, pct) - } - None => "?".into(), - }; - let data_avail = avail_str(status.and_then(|x| x.data_disk_avail)); - let meta_avail = avail_str(status.and_then(|x| x.meta_disk_avail)); - table.push(format!( - " {:?}\t{}\t{}\t{}\t{}\t{}\t{}", - id, hostname, zone, capacity, parts, data_avail, meta_avail - )); - } - write!( - &mut ret, - "Storage nodes:\n{}", - format_table_to_string(table) - ) - .unwrap(); - - let meta_part_avail = node_partition_count - .iter() - .filter_map(|(id, parts)| { - node_info - .get(id) - .and_then(|x| x.status.meta_disk_avail) - .map(|c| c.0 / *parts) - }) - .collect::>(); - let data_part_avail = node_partition_count - .iter() - .filter_map(|(id, parts)| { - node_info - .get(id) - .and_then(|x| x.status.data_disk_avail) - .map(|c| c.0 / *parts) - }) - .collect::>(); - if !meta_part_avail.is_empty() && !data_part_avail.is_empty() { - let meta_avail = - bytesize::ByteSize(meta_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); - let data_avail = - bytesize::ByteSize(data_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); - writeln!( - &mut ret, - "\nEstimated available storage space cluster-wide (might be lower in practice):" - ) - .unwrap(); - if meta_part_avail.len() < node_partition_count.len() - || data_part_avail.len() < node_partition_count.len() - { - writeln!(&mut ret, " data: < {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: < {}", meta_avail).unwrap(); - writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap(); - } else { - writeln!(&mut ret, " data: {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: {}", meta_avail).unwrap(); - } - } - - ret - } - - fn gather_table_stats(&self, t: &Arc>) -> Result - where - F: TableSchema + 'static, - R: TableReplication + 'static, - { - let data_len = t.data.store.len().map_err(GarageError::from)?.to_string(); - let mkl_len = t.merkle_updater.merkle_tree_len()?.to_string(); - - Ok(format!( - " {}\t{}\t{}\t{}\t{}", - F::TABLE_NAME, - data_len, - mkl_len, - t.merkle_updater.todo_len()?, - t.data.gc_todo_len()? - )) - } } #[async_trait] @@ -325,7 +102,6 @@ impl EndpointHandler for AdminRpcHandler { ) -> Result { match message { AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, - AdminRpc::Stats(opt) => self.handle_stats(opt.clone()).await, m => Err(GarageError::unexpected_rpc_message(m).into()), } } diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 0de4ead8..dccdc295 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -45,6 +45,7 @@ impl Cli { Command::Worker(wo) => self.cmd_worker(wo).await, Command::Block(bo) => self.cmd_block(bo).await, Command::Meta(mo) => self.cmd_meta(mo).await, + Command::Stats(so) => self.cmd_stats(so).await, // TODO Command::Repair(ro) => cli_v1::cmd_admin( @@ -54,11 +55,6 @@ impl Cli { ) .await .ok_or_message("cli_v1"), - Command::Stats(so) => { - cli_v1::cmd_admin(&self.admin_rpc_endpoint, self.rpc_host, AdminRpc::Stats(so)) - .await - .ok_or_message("cli_v1") - } _ => unreachable!(), } diff --git a/src/garage/cli_v2/node.rs b/src/garage/cli_v2/node.rs index c5f28300..b1915dc4 100644 --- a/src/garage/cli_v2/node.rs +++ b/src/garage/cli_v2/node.rs @@ -33,4 +33,35 @@ impl Cli { Ok(()) } + + pub async fn cmd_stats(&self, cmd: StatsOpt) -> Result<(), Error> { + let res = self + .api_request(GetNodeStatisticsRequest { + node: if cmd.all_nodes { + "*".to_string() + } else { + hex::encode(self.rpc_host) + }, + body: LocalGetNodeStatisticsRequest, + }) + .await?; + + for (node, res) in res.success.iter() { + println!("======================"); + println!("Stats for node {:.16}:\n", node); + println!("{}\n", res.freeform); + } + + for (node, err) in res.error.iter() { + println!("======================"); + println!("Node {:.16}: error: {}\n", node, err); + } + + let res = self.api_request(GetClusterStatisticsRequest).await?; + println!("======================"); + println!("Cluster statistics:\n"); + println!("{}\n", res.freeform); + + Ok(()) + } } From f914db057a85e0fa70f319ee3af85998a551af96 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 5 Feb 2025 15:36:47 +0100 Subject: [PATCH 050/192] cli_v2: implement LaunchRepairOperation and remove old stuff --- Cargo.lock | 2 +- src/api/admin/Cargo.toml | 1 + src/api/admin/api.rs | 34 ++++ src/api/admin/lib.rs | 1 + .../repair/online.rs => api/admin/repair.rs} | 171 ++++++++++-------- src/api/admin/router_v2.rs | 1 + src/garage/Cargo.toml | 2 - src/garage/admin/mod.rs | 108 ----------- src/garage/cli/cmd.rs | 21 --- src/garage/cli/layout.rs | 2 +- src/garage/cli/mod.rs | 9 +- .../{repair/offline.rs => cli/repair.rs} | 0 src/garage/cli/structs.rs | 64 +++---- src/garage/cli_v2/mod.rs | 14 +- src/garage/cli_v2/node.rs | 48 ++++- src/garage/main.rs | 13 +- src/garage/repair/mod.rs | 2 - src/garage/server.rs | 4 - 18 files changed, 214 insertions(+), 283 deletions(-) rename src/{garage/repair/online.rs => api/admin/repair.rs} (69%) delete mode 100644 src/garage/admin/mod.rs delete mode 100644 src/garage/cli/cmd.rs rename src/garage/{repair/offline.rs => cli/repair.rs} (100%) delete mode 100644 src/garage/repair/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9ba0d553..0b86147b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1258,7 +1258,6 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry-prometheus", "parse_duration", - "serde", "serde_json", "sha1", "sha2", @@ -1282,6 +1281,7 @@ dependencies = [ "format_table", "futures", "garage_api_common", + "garage_block", "garage_model", "garage_rpc", "garage_table", diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 7b1ad2f0..9ac099e8 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -16,6 +16,7 @@ path = "lib.rs" [dependencies] format_table.workspace = true garage_model.workspace = true +garage_block.workspace = true garage_table.workspace = true garage_util.workspace = true garage_rpc.workspace = true diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 4caae02c..48c9ee0b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -81,6 +81,7 @@ admin_endpoints![ CreateMetadataSnapshot, GetNodeStatistics, GetClusterStatistics, + LaunchRepairOperation, // Worker operations ListWorkers, @@ -99,6 +100,7 @@ local_admin_endpoints![ // Node operations CreateMetadataSnapshot, GetNodeStatistics, + LaunchRepairOperation, // Background workers ListWorkers, GetWorkerInfo, @@ -663,6 +665,38 @@ pub struct GetClusterStatisticsResponse { pub freeform: String, } +// ---- LaunchRepairOperation ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalLaunchRepairOperationRequest { + pub repair_type: RepairType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RepairType { + Tables, + Blocks, + Versions, + MultipartUploads, + BlockRefs, + BlockRc, + Rebalance, + Scrub(ScrubCommand), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScrubCommand { + Start, + Pause, + Resume, + Cancel, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalLaunchRepairOperationResponse; + // ********************************************** // Worker operations // ********************************************** diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index cc673eef..fe4b0598 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -17,6 +17,7 @@ mod special; mod block; mod node; +mod repair; mod worker; use std::sync::Arc; diff --git a/src/garage/repair/online.rs b/src/api/admin/repair.rs similarity index 69% rename from src/garage/repair/online.rs rename to src/api/admin/repair.rs index 2c5227d2..19bb4d51 100644 --- a/src/garage/repair/online.rs +++ b/src/api/admin/repair.rs @@ -4,6 +4,14 @@ use std::time::Duration; use async_trait::async_trait; use tokio::sync::watch; +use garage_util::background::*; +use garage_util::data::*; +use garage_util::error::{Error as GarageError, OkOrMessage}; +use garage_util::migrate::Migrate; + +use garage_table::replication::*; +use garage_table::*; + use garage_block::manager::BlockManager; use garage_block::repair::ScrubWorkerCommand; @@ -13,82 +21,77 @@ use garage_model::s3::mpu_table::*; use garage_model::s3::object_table::*; use garage_model::s3::version_table::*; -use garage_table::replication::*; -use garage_table::*; - -use garage_util::background::*; -use garage_util::data::*; -use garage_util::error::Error; -use garage_util::migrate::Migrate; - -use crate::*; +use crate::api::*; +use crate::error::Error; +use crate::{Admin, RequestHandler}; const RC_REPAIR_ITER_COUNT: usize = 64; -pub async fn launch_online_repair( - garage: &Arc, - bg: &BackgroundRunner, - opt: RepairOpt, -) -> Result<(), Error> { - match opt.what { - RepairWhat::Tables => { - info!("Launching a full sync of tables"); - garage.bucket_table.syncer.add_full_sync()?; - garage.object_table.syncer.add_full_sync()?; - garage.version_table.syncer.add_full_sync()?; - garage.block_ref_table.syncer.add_full_sync()?; - garage.key_table.syncer.add_full_sync()?; - } - RepairWhat::Versions => { - info!("Repairing the versions table"); - bg.spawn_worker(TableRepairWorker::new(garage.clone(), RepairVersions)); - } - RepairWhat::MultipartUploads => { - info!("Repairing the multipart uploads table"); - bg.spawn_worker(TableRepairWorker::new(garage.clone(), RepairMpu)); - } - RepairWhat::BlockRefs => { - info!("Repairing the block refs table"); - bg.spawn_worker(TableRepairWorker::new(garage.clone(), RepairBlockRefs)); - } - RepairWhat::BlockRc => { - info!("Repairing the block reference counters"); - bg.spawn_worker(BlockRcRepair::new( - garage.block_manager.clone(), - garage.block_ref_table.clone(), - )); - } - RepairWhat::Blocks => { - info!("Repairing the stored blocks"); - bg.spawn_worker(garage_block::repair::RepairWorker::new( - garage.block_manager.clone(), - )); - } - RepairWhat::Scrub { cmd } => { - let cmd = match cmd { - ScrubCmd::Start => ScrubWorkerCommand::Start, - ScrubCmd::Pause => ScrubWorkerCommand::Pause(Duration::from_secs(3600 * 24)), - ScrubCmd::Resume => ScrubWorkerCommand::Resume, - ScrubCmd::Cancel => ScrubWorkerCommand::Cancel, - ScrubCmd::SetTranquility { tranquility } => { - garage - .block_manager - .scrub_persister - .set_with(|x| x.tranquility = tranquility)?; - return Ok(()); - } - }; - info!("Sending command to scrub worker: {:?}", cmd); - garage.block_manager.send_scrub_command(cmd).await?; - } - RepairWhat::Rebalance => { - info!("Rebalancing the stored blocks among storage locations"); - bg.spawn_worker(garage_block::repair::RebalanceWorker::new( - garage.block_manager.clone(), - )); +#[async_trait] +impl RequestHandler for LocalLaunchRepairOperationRequest { + type Response = LocalLaunchRepairOperationResponse; + + async fn handle( + self, + garage: &Arc, + admin: &Admin, + ) -> Result { + let bg = &admin.background; + match self.repair_type { + RepairType::Tables => { + info!("Launching a full sync of tables"); + garage.bucket_table.syncer.add_full_sync()?; + garage.object_table.syncer.add_full_sync()?; + garage.version_table.syncer.add_full_sync()?; + garage.block_ref_table.syncer.add_full_sync()?; + garage.key_table.syncer.add_full_sync()?; + } + RepairType::Versions => { + info!("Repairing the versions table"); + bg.spawn_worker(TableRepairWorker::new(garage.clone(), RepairVersions)); + } + RepairType::MultipartUploads => { + info!("Repairing the multipart uploads table"); + bg.spawn_worker(TableRepairWorker::new(garage.clone(), RepairMpu)); + } + RepairType::BlockRefs => { + info!("Repairing the block refs table"); + bg.spawn_worker(TableRepairWorker::new(garage.clone(), RepairBlockRefs)); + } + RepairType::BlockRc => { + info!("Repairing the block reference counters"); + bg.spawn_worker(BlockRcRepair::new( + garage.block_manager.clone(), + garage.block_ref_table.clone(), + )); + } + RepairType::Blocks => { + info!("Repairing the stored blocks"); + bg.spawn_worker(garage_block::repair::RepairWorker::new( + garage.block_manager.clone(), + )); + } + RepairType::Scrub(cmd) => { + let cmd = match cmd { + ScrubCommand::Start => ScrubWorkerCommand::Start, + ScrubCommand::Pause => { + ScrubWorkerCommand::Pause(Duration::from_secs(3600 * 24)) + } + ScrubCommand::Resume => ScrubWorkerCommand::Resume, + ScrubCommand::Cancel => ScrubWorkerCommand::Cancel, + }; + info!("Sending command to scrub worker: {:?}", cmd); + garage.block_manager.send_scrub_command(cmd).await?; + } + RepairType::Rebalance => { + info!("Rebalancing the stored blocks among storage locations"); + bg.spawn_worker(garage_block::repair::RebalanceWorker::new( + garage.block_manager.clone(), + )); + } } + Ok(LocalLaunchRepairOperationResponse) } - Ok(()) } // ---- @@ -103,7 +106,7 @@ trait TableRepair: Send + Sync + 'static { &mut self, garage: &Garage, entry: <::T as TableSchema>::E, - ) -> Result; + ) -> Result; } struct TableRepairWorker { @@ -139,7 +142,10 @@ impl Worker for TableRepairWorker { } } - async fn work(&mut self, _must_exit: &mut watch::Receiver) -> Result { + async fn work( + &mut self, + _must_exit: &mut watch::Receiver, + ) -> Result { let (item_bytes, next_pos) = match R::table(&self.garage).data.store.get_gt(&self.pos)? { Some((k, v)) => (v, k), None => { @@ -182,7 +188,7 @@ impl TableRepair for RepairVersions { &garage.version_table } - async fn process(&mut self, garage: &Garage, version: Version) -> Result { + async fn process(&mut self, garage: &Garage, version: Version) -> Result { if !version.deleted.get() { let ref_exists = match &version.backlink { VersionBacklink::Object { bucket_id, key } => garage @@ -229,7 +235,11 @@ impl TableRepair for RepairBlockRefs { &garage.block_ref_table } - async fn process(&mut self, garage: &Garage, mut block_ref: BlockRef) -> Result { + async fn process( + &mut self, + garage: &Garage, + mut block_ref: BlockRef, + ) -> Result { if !block_ref.deleted.get() { let ref_exists = garage .version_table @@ -265,7 +275,11 @@ impl TableRepair for RepairMpu { &garage.mpu_table } - async fn process(&mut self, garage: &Garage, mut mpu: MultipartUpload) -> Result { + async fn process( + &mut self, + garage: &Garage, + mut mpu: MultipartUpload, + ) -> Result { if !mpu.deleted.get() { let ref_exists = garage .object_table @@ -332,7 +346,10 @@ impl Worker for BlockRcRepair { } } - async fn work(&mut self, _must_exit: &mut watch::Receiver) -> Result { + async fn work( + &mut self, + _must_exit: &mut watch::Receiver, + ) -> Result { for _i in 0..RC_REPAIR_ITER_COUNT { let next1 = self .block_manager diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index a0f415c2..4d5c015e 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -63,6 +63,7 @@ impl AdminApiRequest { POST CreateMetadataSnapshot (default::body, query::node), GET GetNodeStatistics (default::body, query::node), GET GetClusterStatistics (), + POST LaunchRepairOperation (body_field, query::node), // Worker APIs POST ListWorkers (body_field, query::node), POST GetWorkerInfo (body_field, query::node), diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index 4f823fc6..c566c3e0 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -49,8 +49,6 @@ sodiumoxide.workspace = true structopt.workspace = true git-version.workspace = true -serde.workspace = true - futures.workspace = true tokio.workspace = true diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs deleted file mode 100644 index c4ab2810..00000000 --- a/src/garage/admin/mod.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -use garage_util::background::BackgroundRunner; -use garage_util::error::Error as GarageError; - -use garage_rpc::*; - -use garage_model::garage::Garage; -use garage_model::helper::error::Error; - -use crate::cli::*; -use crate::repair::online::launch_online_repair; - -pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; - -#[derive(Debug, Serialize, Deserialize)] -#[allow(clippy::large_enum_variant)] -pub enum AdminRpc { - LaunchRepair(RepairOpt), - - // Replies - Ok(String), -} - -impl Rpc for AdminRpc { - type Response = Result; -} - -pub struct AdminRpcHandler { - garage: Arc, - background: Arc, - endpoint: Arc>, -} - -impl AdminRpcHandler { - pub fn new(garage: Arc, background: Arc) -> Arc { - let endpoint = garage.system.netapp.endpoint(ADMIN_RPC_PATH.into()); - let admin = Arc::new(Self { - garage, - background, - endpoint, - }); - admin.endpoint.set_handler(admin.clone()); - admin - } - - // ================ REPAIR COMMANDS ==================== - - async fn handle_launch_repair(self: &Arc, opt: RepairOpt) -> Result { - if !opt.yes { - return Err(Error::BadRequest( - "Please provide the --yes flag to initiate repair operations.".to_string(), - )); - } - if opt.all_nodes { - let mut opt_to_send = opt.clone(); - opt_to_send.all_nodes = false; - - let mut failures = vec![]; - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in all_nodes.iter() { - let node = (*node).into(); - let resp = self - .endpoint - .call( - &node, - AdminRpc::LaunchRepair(opt_to_send.clone()), - PRIO_NORMAL, - ) - .await; - if !matches!(resp, Ok(Ok(_))) { - failures.push(node); - } - } - if failures.is_empty() { - Ok(AdminRpc::Ok("Repair launched on all nodes".to_string())) - } else { - Err(Error::BadRequest(format!( - "Could not launch repair on nodes: {:?} (launched successfully on other nodes)", - failures - ))) - } - } else { - launch_online_repair(&self.garage, &self.background, opt).await?; - Ok(AdminRpc::Ok(format!( - "Repair launched on {:?}", - self.garage.system.id - ))) - } - } -} - -#[async_trait] -impl EndpointHandler for AdminRpcHandler { - async fn handle( - self: &Arc, - message: &AdminRpc, - _from: NodeID, - ) -> Result { - match message { - AdminRpc::LaunchRepair(opt) => self.handle_launch_repair(opt.clone()).await, - m => Err(GarageError::unexpected_rpc_message(m).into()), - } - } -} diff --git a/src/garage/cli/cmd.rs b/src/garage/cli/cmd.rs deleted file mode 100644 index 1a9c7841..00000000 --- a/src/garage/cli/cmd.rs +++ /dev/null @@ -1,21 +0,0 @@ -use garage_rpc::*; - -use garage_model::helper::error::Error as HelperError; - -use crate::admin::*; - -pub async fn cmd_admin( - rpc_cli: &Endpoint, - rpc_host: NodeID, - args: AdminRpc, -) -> Result<(), HelperError> { - match rpc_cli.call(&rpc_host, args, PRIO_NORMAL).await?? { - AdminRpc::Ok(msg) => { - println!("{}", msg); - } - r => { - error!("Unexpected response: {:?}", r); - } - } - Ok(()) -} diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index 15040aaa..bb77cc2a 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -7,7 +7,7 @@ use garage_rpc::layout::*; use garage_rpc::system::*; use garage_rpc::*; -use crate::cli::*; +use crate::cli::structs::*; pub async fn cmd_show_layout( rpc_cli: &Endpoint, diff --git a/src/garage/cli/mod.rs b/src/garage/cli/mod.rs index c15afda1..e007808b 100644 --- a/src/garage/cli/mod.rs +++ b/src/garage/cli/mod.rs @@ -1,10 +1,7 @@ -pub(crate) mod cmd; -pub(crate) mod init; -pub(crate) mod layout; pub(crate) mod structs; pub(crate) mod convert_db; +pub(crate) mod init; +pub(crate) mod repair; -pub(crate) use cmd::*; -pub(crate) use init::*; -pub(crate) use structs::*; +pub(crate) mod layout; diff --git a/src/garage/repair/offline.rs b/src/garage/cli/repair.rs similarity index 100% rename from src/garage/repair/offline.rs rename to src/garage/cli/repair.rs diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 4ec35e68..c6471515 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -1,4 +1,3 @@ -use serde::{Deserialize, Serialize}; use structopt::StructOpt; use garage_util::version::garage_version; @@ -190,7 +189,7 @@ pub struct SkipDeadNodesOpt { pub(crate) allow_missing_data: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub enum BucketOperation { /// List buckets #[structopt(name = "list", version = garage_version())] @@ -237,7 +236,7 @@ pub enum BucketOperation { CleanupIncompleteUploads(CleanupIncompleteUploadsOpt), } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct WebsiteOpt { /// Create #[structopt(long = "allow")] @@ -259,13 +258,13 @@ pub struct WebsiteOpt { pub error_document: Option, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct BucketOpt { /// Bucket name pub name: String, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct DeleteBucketOpt { /// Bucket name pub name: String, @@ -275,7 +274,7 @@ pub struct DeleteBucketOpt { pub yes: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct AliasBucketOpt { /// Existing bucket name (its alias in global namespace or its full hex uuid) pub existing_bucket: String, @@ -288,7 +287,7 @@ pub struct AliasBucketOpt { pub local: Option, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct UnaliasBucketOpt { /// Bucket name pub name: String, @@ -298,7 +297,7 @@ pub struct UnaliasBucketOpt { pub local: Option, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct PermBucketOpt { /// Access key name or ID #[structopt(long = "key")] @@ -321,7 +320,7 @@ pub struct PermBucketOpt { pub bucket: String, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct SetQuotasOpt { /// Bucket name pub bucket: String, @@ -336,7 +335,7 @@ pub struct SetQuotasOpt { pub max_objects: Option, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct CleanupIncompleteUploadsOpt { /// Abort multipart uploads older than this value #[structopt(long = "older-than", default_value = "1d")] @@ -347,7 +346,7 @@ pub struct CleanupIncompleteUploadsOpt { pub buckets: Vec, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub enum KeyOperation { /// List keys #[structopt(name = "list", version = garage_version())] @@ -382,7 +381,7 @@ pub enum KeyOperation { Import(KeyImportOpt), } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct KeyInfoOpt { /// ID or name of the key pub key_pattern: String, @@ -391,14 +390,14 @@ pub struct KeyInfoOpt { pub show_secret: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct KeyNewOpt { /// Name of the key #[structopt(default_value = "Unnamed key")] pub name: String, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct KeyRenameOpt { /// ID or name of the key pub key_pattern: String, @@ -407,7 +406,7 @@ pub struct KeyRenameOpt { pub new_name: String, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct KeyDeleteOpt { /// ID or name of the key pub key_pattern: String, @@ -417,7 +416,7 @@ pub struct KeyDeleteOpt { pub yes: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct KeyPermOpt { /// ID or name of the key pub key_pattern: String, @@ -427,7 +426,7 @@ pub struct KeyPermOpt { pub create_bucket: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug)] +#[derive(StructOpt, Debug)] pub struct KeyImportOpt { /// Access key ID pub key_id: String, @@ -444,7 +443,7 @@ pub struct KeyImportOpt { pub yes: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Clone)] +#[derive(StructOpt, Debug, Clone)] pub struct RepairOpt { /// Launch repair operation on all nodes #[structopt(short = "a", long = "all-nodes")] @@ -458,7 +457,7 @@ pub struct RepairOpt { pub what: RepairWhat, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum RepairWhat { /// Do a full sync of metadata tables #[structopt(name = "tables", version = garage_version())] @@ -489,7 +488,7 @@ pub enum RepairWhat { Rebalance, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum ScrubCmd { /// Start scrub #[structopt(name = "start", version = garage_version())] @@ -503,15 +502,9 @@ pub enum ScrubCmd { /// Cancel scrub in progress #[structopt(name = "cancel", version = garage_version())] Cancel, - /// Set tranquility level for in-progress and future scrubs - #[structopt(name = "set-tranquility", version = garage_version())] - SetTranquility { - #[structopt()] - tranquility: u32, - }, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Clone)] +#[derive(StructOpt, Debug, Clone)] pub struct OfflineRepairOpt { /// Confirm the launch of the repair operation #[structopt(long = "yes")] @@ -521,7 +514,7 @@ pub struct OfflineRepairOpt { pub what: OfflineRepairWhat, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum OfflineRepairWhat { /// Repair K2V item counters #[cfg(feature = "k2v")] @@ -532,19 +525,14 @@ pub enum OfflineRepairWhat { ObjectCounters, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Clone)] +#[derive(StructOpt, Debug, Clone)] pub struct StatsOpt { /// Gather statistics from all nodes #[structopt(short = "a", long = "all-nodes")] pub all_nodes: bool, - - /// Don't show global cluster stats (internal use in RPC) - #[structopt(skip)] - #[serde(default)] - pub skip_global: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum WorkerOperation { /// List all workers on Garage node #[structopt(name = "list", version = garage_version())] @@ -577,7 +565,7 @@ pub enum WorkerOperation { }, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone, Copy)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone, Copy)] pub struct WorkerListOpt { /// Show only busy workers #[structopt(short = "b", long = "busy")] @@ -587,7 +575,7 @@ pub struct WorkerListOpt { pub errors: bool, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum BlockOperation { /// List all blocks that currently have a resync error #[structopt(name = "list-errors", version = garage_version())] @@ -619,7 +607,7 @@ pub enum BlockOperation { }, } -#[derive(Serialize, Deserialize, StructOpt, Debug, Eq, PartialEq, Clone, Copy)] +#[derive(StructOpt, Debug, Eq, PartialEq, Clone, Copy)] pub enum MetaOperation { /// Save a snapshot of the metadata db file #[structopt(name = "snapshot", version = garage_version())] diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index dccdc295..28c7c824 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -20,14 +20,10 @@ use garage_api_admin::api::*; use garage_api_admin::api_server::{AdminRpc as ProxyRpc, AdminRpcResponse as ProxyRpcResponse}; use garage_api_admin::RequestHandler; -use crate::admin::*; -use crate::cli as cli_v1; use crate::cli::structs::*; -use crate::cli::Command; pub struct Cli { pub system_rpc_endpoint: Arc>, - pub admin_rpc_endpoint: Arc>, pub proxy_rpc_endpoint: Arc>, pub rpc_host: NodeID, } @@ -46,15 +42,7 @@ impl Cli { Command::Block(bo) => self.cmd_block(bo).await, Command::Meta(mo) => self.cmd_meta(mo).await, Command::Stats(so) => self.cmd_stats(so).await, - - // TODO - Command::Repair(ro) => cli_v1::cmd_admin( - &self.admin_rpc_endpoint, - self.rpc_host, - AdminRpc::LaunchRepair(ro), - ) - .await - .ok_or_message("cli_v1"), + Command::Repair(ro) => self.cmd_repair(ro).await, _ => unreachable!(), } diff --git a/src/garage/cli_v2/node.rs b/src/garage/cli_v2/node.rs index b1915dc4..c5d0cdea 100644 --- a/src/garage/cli_v2/node.rs +++ b/src/garage/cli_v2/node.rs @@ -27,7 +27,7 @@ impl Cli { table.push(format!("{:.16}\tError: {}", node, err)); } for (node, _) in res.success.iter() { - table.push(format!("{:.16}\tOk", node)); + table.push(format!("{:.16}\tSnapshot created", node)); } format_table(table); @@ -64,4 +64,50 @@ impl Cli { Ok(()) } + + pub async fn cmd_repair(&self, cmd: RepairOpt) -> Result<(), Error> { + if !cmd.yes { + return Err(Error::Message( + "Please add --yes to start the repair operation".into(), + )); + } + + let repair_type = match cmd.what { + RepairWhat::Tables => RepairType::Tables, + RepairWhat::Blocks => RepairType::Blocks, + RepairWhat::Versions => RepairType::Versions, + RepairWhat::MultipartUploads => RepairType::MultipartUploads, + RepairWhat::BlockRefs => RepairType::BlockRefs, + RepairWhat::BlockRc => RepairType::BlockRc, + RepairWhat::Rebalance => RepairType::Rebalance, + RepairWhat::Scrub { cmd } => RepairType::Scrub(match cmd { + ScrubCmd::Start => ScrubCommand::Start, + ScrubCmd::Cancel => ScrubCommand::Cancel, + ScrubCmd::Pause => ScrubCommand::Pause, + ScrubCmd::Resume => ScrubCommand::Resume, + }), + }; + + let res = self + .api_request(LaunchRepairOperationRequest { + node: if cmd.all_nodes { + "*".to_string() + } else { + hex::encode(self.rpc_host) + }, + body: LocalLaunchRepairOperationRequest { repair_type }, + }) + .await?; + + let mut table = vec![]; + for (node, err) in res.error.iter() { + table.push(format!("{:.16}\tError: {}", node, err)); + } + for (node, _) in res.success.iter() { + table.push(format!("{:.16}\tRepair launched", node)); + } + format_table(table); + + Ok(()) + } } diff --git a/src/garage/main.rs b/src/garage/main.rs index 022841f5..2a88d760 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -4,10 +4,8 @@ #[macro_use] extern crate tracing; -mod admin; mod cli; mod cli_v2; -mod repair; mod secrets; mod server; #[cfg(feature = "telemetry-otlp")] @@ -37,8 +35,7 @@ use garage_rpc::*; use garage_api_admin::api_server::{AdminRpc as ProxyRpc, ADMIN_RPC_PATH as PROXY_RPC_PATH}; -use admin::*; -use cli::*; +use cli::structs::*; use secrets::Secrets; #[derive(StructOpt, Debug)] @@ -146,13 +143,13 @@ async fn main() { let res = match opt.cmd { Command::Server => server::run_server(opt.config_file, opt.secrets).await, Command::OfflineRepair(repair_opt) => { - repair::offline::offline_repair(opt.config_file, opt.secrets, repair_opt).await + cli::repair::offline_repair(opt.config_file, opt.secrets, repair_opt).await } Command::ConvertDb(conv_opt) => { cli::convert_db::do_conversion(conv_opt).map_err(From::from) } Command::Node(NodeOperation::NodeId(node_id_opt)) => { - node_id_command(opt.config_file, node_id_opt.quiet) + cli::init::node_id_command(opt.config_file, node_id_opt.quiet) } _ => cli_command(opt).await, }; @@ -253,7 +250,7 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { (id, addrs[0], false) } else { let node_id = garage_rpc::system::read_node_id(&config.as_ref().unwrap().metadata_dir) - .err_context(READ_KEY_ERROR)?; + .err_context(cli::init::READ_KEY_ERROR)?; if let Some(a) = config.as_ref().and_then(|c| c.rpc_public_addr.as_ref()) { use std::net::ToSocketAddrs; let a = a @@ -283,12 +280,10 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { } let system_rpc_endpoint = netapp.endpoint::(SYSTEM_RPC_PATH.into()); - let admin_rpc_endpoint = netapp.endpoint::(ADMIN_RPC_PATH.into()); let proxy_rpc_endpoint = netapp.endpoint::(PROXY_RPC_PATH.into()); let cli = cli_v2::Cli { system_rpc_endpoint, - admin_rpc_endpoint, proxy_rpc_endpoint, rpc_host: id, }; diff --git a/src/garage/repair/mod.rs b/src/garage/repair/mod.rs deleted file mode 100644 index 4699ace5..00000000 --- a/src/garage/repair/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod offline; -pub mod online; diff --git a/src/garage/server.rs b/src/garage/server.rs index e629041c..131cc8aa 100644 --- a/src/garage/server.rs +++ b/src/garage/server.rs @@ -14,7 +14,6 @@ use garage_web::WebServer; #[cfg(feature = "k2v")] use garage_api_k2v::api_server::K2VApiServer; -use crate::admin::*; use crate::secrets::{fill_secrets, Secrets}; #[cfg(feature = "telemetry-otlp")] use crate::tracing_setup::*; @@ -74,9 +73,6 @@ pub async fn run_server(config_file: PathBuf, secrets: Secrets) -> Result<(), Er info!("Launching internal Garage cluster communications..."); let run_system = tokio::spawn(garage.system.clone().run(watch_cancel.clone())); - info!("Create admin RPC handler..."); - AdminRpcHandler::new(garage.clone(), background.clone()); - // ---- Launch public-facing API servers ---- let mut servers = vec![]; From 7c8fc04b9645d4cbccd30749735d30aad18c9575 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 5 Feb 2025 19:37:38 +0100 Subject: [PATCH 051/192] massively speed up compilation of garage_api_admin by not using async_trait --- src/api/admin/api.rs | 1 - src/api/admin/block.rs | 6 ------ src/api/admin/bucket.rs | 12 ------------ src/api/admin/cluster.rs | 9 --------- src/api/admin/key.rs | 8 -------- src/api/admin/lib.rs | 7 ++----- src/api/admin/macros.rs | 3 --- src/api/admin/node.rs | 5 ----- src/api/admin/repair.rs | 9 ++------- src/api/admin/special.rs | 6 ------ src/api/admin/worker.rs | 6 ------ 11 files changed, 4 insertions(+), 68 deletions(-) diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 48c9ee0b..97cde158 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -3,7 +3,6 @@ use std::convert::TryFrom; use std::net::SocketAddr; use std::sync::Arc; -use async_trait::async_trait; use paste::paste; use serde::{Deserialize, Serialize}; diff --git a/src/api/admin/block.rs b/src/api/admin/block.rs index 8f0e63eb..73d186a6 100644 --- a/src/api/admin/block.rs +++ b/src/api/admin/block.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use async_trait::async_trait; - use garage_util::data::*; use garage_util::error::Error as GarageError; use garage_util::time::now_msec; @@ -18,7 +16,6 @@ use crate::api::*; use crate::error::*; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for LocalListBlockErrorsRequest { type Response = LocalListBlockErrorsResponse; @@ -43,7 +40,6 @@ impl RequestHandler for LocalListBlockErrorsRequest { } } -#[async_trait] impl RequestHandler for LocalGetBlockInfoRequest { type Response = LocalGetBlockInfoResponse; @@ -109,7 +105,6 @@ impl RequestHandler for LocalGetBlockInfoRequest { } } -#[async_trait] impl RequestHandler for LocalRetryBlockResyncRequest { type Response = LocalRetryBlockResyncResponse; @@ -143,7 +138,6 @@ impl RequestHandler for LocalRetryBlockResyncRequest { } } -#[async_trait] impl RequestHandler for LocalPurgeBlocksRequest { type Response = LocalPurgeBlocksResponse; diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 73e63df0..d2bb62e0 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -2,8 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; - use garage_util::crdt::*; use garage_util::data::*; use garage_util::time::*; @@ -23,7 +21,6 @@ use crate::api::*; use crate::error::*; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for ListBucketsRequest { type Response = ListBucketsResponse; @@ -74,7 +71,6 @@ impl RequestHandler for ListBucketsRequest { } } -#[async_trait] impl RequestHandler for GetBucketInfoRequest { type Response = GetBucketInfoResponse; @@ -230,7 +226,6 @@ async fn bucket_info_results( Ok(res) } -#[async_trait] impl RequestHandler for CreateBucketRequest { type Response = CreateBucketResponse; @@ -305,7 +300,6 @@ impl RequestHandler for CreateBucketRequest { } } -#[async_trait] impl RequestHandler for DeleteBucketRequest { type Response = DeleteBucketResponse; @@ -358,7 +352,6 @@ impl RequestHandler for DeleteBucketRequest { } } -#[async_trait] impl RequestHandler for UpdateBucketRequest { type Response = UpdateBucketResponse; @@ -409,7 +402,6 @@ impl RequestHandler for UpdateBucketRequest { } } -#[async_trait] impl RequestHandler for CleanupIncompleteUploadsRequest { type Response = CleanupIncompleteUploadsResponse; @@ -435,7 +427,6 @@ impl RequestHandler for CleanupIncompleteUploadsRequest { // ---- BUCKET/KEY PERMISSIONS ---- -#[async_trait] impl RequestHandler for AllowBucketKeyRequest { type Response = AllowBucketKeyResponse; @@ -449,7 +440,6 @@ impl RequestHandler for AllowBucketKeyRequest { } } -#[async_trait] impl RequestHandler for DenyBucketKeyRequest { type Response = DenyBucketKeyResponse; @@ -502,7 +492,6 @@ pub async fn handle_bucket_change_key_perm( // ---- BUCKET ALIASES ---- -#[async_trait] impl RequestHandler for AddBucketAliasRequest { type Response = AddBucketAliasResponse; @@ -537,7 +526,6 @@ impl RequestHandler for AddBucketAliasRequest { } } -#[async_trait] impl RequestHandler for RemoveBucketAliasRequest { type Response = RemoveBucketAliasResponse; diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 6a7a3d69..cb1fa493 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; -use async_trait::async_trait; - use garage_util::crdt::*; use garage_util::data::*; @@ -14,7 +12,6 @@ use crate::api::*; use crate::error::*; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for GetClusterStatusRequest { type Response = GetClusterStatusResponse; @@ -120,7 +117,6 @@ impl RequestHandler for GetClusterStatusRequest { } } -#[async_trait] impl RequestHandler for GetClusterHealthRequest { type Response = GetClusterHealthResponse; @@ -150,7 +146,6 @@ impl RequestHandler for GetClusterHealthRequest { } } -#[async_trait] impl RequestHandler for ConnectClusterNodesRequest { type Response = ConnectClusterNodesResponse; @@ -177,7 +172,6 @@ impl RequestHandler for ConnectClusterNodesRequest { } } -#[async_trait] impl RequestHandler for GetClusterLayoutRequest { type Response = GetClusterLayoutResponse; @@ -241,7 +235,6 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp // ---- update functions ---- -#[async_trait] impl RequestHandler for UpdateClusterLayoutRequest { type Response = UpdateClusterLayoutResponse; @@ -291,7 +284,6 @@ impl RequestHandler for UpdateClusterLayoutRequest { } } -#[async_trait] impl RequestHandler for ApplyClusterLayoutRequest { type Response = ApplyClusterLayoutResponse; @@ -316,7 +308,6 @@ impl RequestHandler for ApplyClusterLayoutRequest { } } -#[async_trait] impl RequestHandler for RevertClusterLayoutRequest { type Response = RevertClusterLayoutResponse; diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 440a8322..dc6ae4e9 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; -use async_trait::async_trait; - use garage_table::*; use garage_model::garage::Garage; @@ -12,7 +10,6 @@ use crate::api::*; use crate::error::*; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for ListKeysRequest { type Response = ListKeysResponse; @@ -38,7 +35,6 @@ impl RequestHandler for ListKeysRequest { } } -#[async_trait] impl RequestHandler for GetKeyInfoRequest { type Response = GetKeyInfoResponse; @@ -66,7 +62,6 @@ impl RequestHandler for GetKeyInfoRequest { } } -#[async_trait] impl RequestHandler for CreateKeyRequest { type Response = CreateKeyResponse; @@ -84,7 +79,6 @@ impl RequestHandler for CreateKeyRequest { } } -#[async_trait] impl RequestHandler for ImportKeyRequest { type Response = ImportKeyResponse; @@ -112,7 +106,6 @@ impl RequestHandler for ImportKeyRequest { } } -#[async_trait] impl RequestHandler for UpdateKeyRequest { type Response = UpdateKeyResponse; @@ -147,7 +140,6 @@ impl RequestHandler for UpdateKeyRequest { } } -#[async_trait] impl RequestHandler for DeleteKeyRequest { type Response = DeleteKeyResponse; diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index fe4b0598..dd9b7ffd 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -22,8 +22,6 @@ mod worker; use std::sync::Arc; -use async_trait::async_trait; - use garage_model::garage::Garage; pub use api_server::AdminApiServer as Admin; @@ -34,13 +32,12 @@ pub enum Authorization { AdminToken, } -#[async_trait] pub trait RequestHandler { type Response; - async fn handle( + fn handle( self, garage: &Arc, admin: &Admin, - ) -> Result; + ) -> impl std::future::Future> + Send; } diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index 4b183bec..df2762fe 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -70,7 +70,6 @@ macro_rules! admin_endpoints { } )* - #[async_trait] impl RequestHandler for AdminApiRequest { type Response = AdminApiResponse; @@ -133,7 +132,6 @@ macro_rules! local_admin_endpoints { } } - #[async_trait] impl RequestHandler for [< $endpoint Request >] { type Response = [< $endpoint Response >]; @@ -202,7 +200,6 @@ macro_rules! local_admin_endpoints { } } - #[async_trait] impl RequestHandler for LocalAdminApiRequest { type Response = LocalAdminApiResponse; diff --git a/src/api/admin/node.rs b/src/api/admin/node.rs index 870db9fb..f6f43d95 100644 --- a/src/api/admin/node.rs +++ b/src/api/admin/node.rs @@ -2,8 +2,6 @@ use std::collections::HashMap; use std::fmt::Write; use std::sync::Arc; -use async_trait::async_trait; - use format_table::format_table_to_string; use garage_util::data::*; @@ -20,7 +18,6 @@ use crate::api::*; use crate::error::Error; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for LocalCreateMetadataSnapshotRequest { type Response = LocalCreateMetadataSnapshotResponse; @@ -34,7 +31,6 @@ impl RequestHandler for LocalCreateMetadataSnapshotRequest { } } -#[async_trait] impl RequestHandler for LocalGetNodeStatisticsRequest { type Response = LocalGetNodeStatisticsResponse; @@ -99,7 +95,6 @@ impl RequestHandler for LocalGetNodeStatisticsRequest { } } -#[async_trait] impl RequestHandler for GetClusterStatisticsRequest { type Response = GetClusterStatisticsResponse; diff --git a/src/api/admin/repair.rs b/src/api/admin/repair.rs index 19bb4d51..113ef636 100644 --- a/src/api/admin/repair.rs +++ b/src/api/admin/repair.rs @@ -27,7 +27,6 @@ use crate::{Admin, RequestHandler}; const RC_REPAIR_ITER_COUNT: usize = 64; -#[async_trait] impl RequestHandler for LocalLaunchRepairOperationRequest { type Response = LocalLaunchRepairOperationResponse; @@ -96,17 +95,16 @@ impl RequestHandler for LocalLaunchRepairOperationRequest { // ---- -#[async_trait] trait TableRepair: Send + Sync + 'static { type T: TableSchema; fn table(garage: &Garage) -> &Table; - async fn process( + fn process( &mut self, garage: &Garage, entry: <::T as TableSchema>::E, - ) -> Result; + ) -> impl std::future::Future> + Send; } struct TableRepairWorker { @@ -180,7 +178,6 @@ impl Worker for TableRepairWorker { struct RepairVersions; -#[async_trait] impl TableRepair for RepairVersions { type T = VersionTable; @@ -227,7 +224,6 @@ impl TableRepair for RepairVersions { struct RepairBlockRefs; -#[async_trait] impl TableRepair for RepairBlockRefs { type T = BlockRefTable; @@ -267,7 +263,6 @@ impl TableRepair for RepairBlockRefs { struct RepairMpu; -#[async_trait] impl TableRepair for RepairMpu { type T = MultipartUploadTable; diff --git a/src/api/admin/special.rs b/src/api/admin/special.rs index 79f1f4d7..0ecf82bc 100644 --- a/src/api/admin/special.rs +++ b/src/api/admin/special.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use async_trait::async_trait; - use http::header::{ ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ALLOW, }; @@ -20,7 +18,6 @@ use crate::api_server::ResBody; use crate::error::*; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for OptionsRequest { type Response = Response; @@ -39,7 +36,6 @@ impl RequestHandler for OptionsRequest { } } -#[async_trait] impl RequestHandler for MetricsRequest { type Response = Response; @@ -76,7 +72,6 @@ impl RequestHandler for MetricsRequest { } } -#[async_trait] impl RequestHandler for HealthRequest { type Response = Response; @@ -110,7 +105,6 @@ impl RequestHandler for HealthRequest { } } -#[async_trait] impl RequestHandler for CheckDomainRequest { type Response = Response; diff --git a/src/api/admin/worker.rs b/src/api/admin/worker.rs index d143e5be..b3f4537b 100644 --- a/src/api/admin/worker.rs +++ b/src/api/admin/worker.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; -use async_trait::async_trait; - use garage_util::background::*; use garage_util::time::now_msec; @@ -12,7 +10,6 @@ use crate::api::*; use crate::error::Error; use crate::{Admin, RequestHandler}; -#[async_trait] impl RequestHandler for LocalListWorkersRequest { type Response = LocalListWorkersResponse; @@ -35,7 +32,6 @@ impl RequestHandler for LocalListWorkersRequest { } } -#[async_trait] impl RequestHandler for LocalGetWorkerInfoRequest { type Response = LocalGetWorkerInfoResponse; @@ -56,7 +52,6 @@ impl RequestHandler for LocalGetWorkerInfoRequest { } } -#[async_trait] impl RequestHandler for LocalGetWorkerVariableRequest { type Response = LocalGetWorkerVariableResponse; @@ -78,7 +73,6 @@ impl RequestHandler for LocalGetWorkerVariableRequest { } } -#[async_trait] impl RequestHandler for LocalSetWorkerVariableRequest { type Response = LocalSetWorkerVariableResponse; From bf0f7924189444683077ce80b7d72303b2b20145 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 8 Feb 2025 15:36:48 +0100 Subject: [PATCH 052/192] add redirect_all to WebsiteConfig model --- src/api/admin/bucket.rs | 1 + src/api/s3/website.rs | 1 + src/garage/admin/bucket.rs | 1 + src/model/bucket_table.rs | 10 ++++++++++ 4 files changed, 13 insertions(+) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index d5fd0e6b..a6025a9e 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -423,6 +423,7 @@ pub async fn handle_update_bucket( "Please specify indexDocument when enabling website access.", )?, error_document: wa.error_document, + redirect_all: None, routing_rules: Vec::new(), })); } else { diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 9c1422b5..69e1b19d 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -238,6 +238,7 @@ impl WebsiteConfiguration { .map(|x| x.suffix.0) .unwrap_or_else(|| "index.html".to_string()), error_document: self.error_document.map(|x| x.key.0), + redirect_all: None, routing_rules: self .routing_rules .rules diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs index a9b4cc50..9afb90d0 100644 --- a/src/garage/admin/bucket.rs +++ b/src/garage/admin/bucket.rs @@ -393,6 +393,7 @@ impl AdminRpcHandler { Some(WebsiteConfig { index_document: query.index_document.clone(), error_document: query.error_document.clone(), + redirect_all: None, routing_rules: Vec::new(), }) } else { diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index b2679323..d15f8977 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -171,9 +171,18 @@ mod v2 { pub struct WebsiteConfig { pub index_document: String, pub error_document: Option, + // this field is currently unused, but present so adding it in the future doesn't + // need a new migration + pub redirect_all: Option, pub routing_rules: Vec, } + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct RedirectAll { + pub hostname: String, + pub protocol: String, + } + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub struct RoutingRule { pub condition: Option, @@ -212,6 +221,7 @@ mod v2 { wc_opt.map(|wc| WebsiteConfig { index_document: wc.index_document, error_document: wc.error_document, + redirect_all: None, routing_rules: vec![], }) }), From 62a3003ccad402dbd004af62724c45df8fb84c07 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 14 Feb 2025 13:21:10 +0100 Subject: [PATCH 053/192] rename Condition into RedirectCondition in internal model --- src/api/s3/website.rs | 8 +++++--- src/model/bucket_table.rs | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 18384932..b714ea23 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -246,9 +246,11 @@ impl WebsiteConfiguration { .into_iter() .map(|rule| { bucket_table::RoutingRule { - condition: rule.condition.map(|condition| bucket_table::Condition { - http_error_code: condition.http_error_code.map(|c| c.0 as u16), - prefix: condition.prefix.map(|p| p.0), + condition: rule.condition.map(|condition| { + bucket_table::RedirectCondition { + http_error_code: condition.http_error_code.map(|c| c.0 as u16), + prefix: condition.prefix.map(|p| p.0), + } }), redirect: bucket_table::Redirect { hostname: rule.redirect.hostname.map(|h| h.0), diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index c4d247d7..7317c36f 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -185,12 +185,12 @@ mod v2 { #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub struct RoutingRule { - pub condition: Option, + pub condition: Option, pub redirect: Redirect, } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] - pub struct Condition { + pub struct RedirectCondition { pub http_error_code: Option, pub prefix: Option, } From 2e03d9058576246e78acb27b4c50efb3bdf6cf2a Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 10:26:01 +0100 Subject: [PATCH 054/192] admi api: remove info about local node from GetClusterStatus and add specific GetNodeInfo endpoint --- doc/api/garage-admin-v2.yml | 34 +++++---------------------------- doc/drafts/admin-api.md | 27 +------------------------- src/api/admin/api.rs | 25 +++++++++++++++++------- src/api/admin/api_server.rs | 38 +++++++++++++++++++++++++++++++++++++ src/api/admin/cluster.rs | 6 ------ src/api/admin/macros.rs | 15 +-------------- src/api/admin/node.rs | 19 +++++++++++++++++++ src/api/admin/router_v2.rs | 6 +++--- 8 files changed, 85 insertions(+), 85 deletions(-) diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml index f9e3c10c..739b4166 100644 --- a/doc/api/garage-admin-v2.yml +++ b/doc/api/garage-admin-v2.yml @@ -84,34 +84,12 @@ paths: application/json: schema: type: object - required: [ node, garageVersion, garageFeatures, rustVersion, dbEngine, knownNodes, layout ] + required: [ layoutVersion, nodes ] properties: - node: - type: string - example: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" - garageVersion: - type: string - example: "v2.0.0" - garageFeatures: - type: array - items: - type: string - example: - - "k2v" - - "lmdb" - - "sqlite" - - "consul-discovery" - - "kubernetes-discovery" - - "metrics" - - "telemetry-otlp" - - "bundled-libs" - rustVersion: - type: string - example: "1.68.0" - dbEngine: - type: string - example: "LMDB (using Heed crate)" - knownNodes: + layoutVersion: + type: integer + example: 1 + nodes: type: array example: - id: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" @@ -131,8 +109,6 @@ paths: hostname: neptune items: $ref: '#/components/schemas/NodeNetworkInfo' - layout: - $ref: '#/components/schemas/ClusterLayout' /ConnectClusterNodes: post: diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index 029c7ddd..0613c535 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -68,26 +68,13 @@ Returns HTTP 200 Ok if yes, or HTTP 4xx if no website is available for this doma Returns the cluster's current status in JSON, including: -- ID of the node being queried and its version of the Garage daemon - Live nodes - Currently configured cluster layout -- Staged changes to the cluster layout Example response body: ```json { - "node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df", - "garageVersion": "v2.0.0", - "garageFeatures": [ - "k2v", - "lmdb", - "sqlite", - "metrics", - "bundled-libs" - ], - "rustVersion": "1.68.0", - "dbEngine": "LMDB (using Heed crate)", "layoutVersion": 5, "nodes": [ { @@ -362,19 +349,7 @@ layout, as well as the description of the layout as returned by GetClusterLayout Clears all of the staged layout changes. -Request body format: - -```json -{ - "version": 13 -} -``` - -Reverting the staged changes is done by incrementing the version number -and clearing the contents of the staged change list. -Similarly to the CLI, the body must include the incremented -version number, which MUST be 1 + the value of the currently -existing layout in the cluster. +This requests contains an empty body. This returns the new cluster layout with all changes reverted, as returned by GetClusterLayout. diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 97cde158..4ab28e2d 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -10,10 +10,9 @@ use garage_rpc::*; use garage_model::garage::Garage; -use garage_api_common::common_error::CommonErrorDerivative; use garage_api_common::helpers::is_default; -use crate::api_server::{AdminRpc, AdminRpcResponse}; +use crate::api_server::{find_matching_nodes, AdminRpc, AdminRpcResponse}; use crate::error::Error; use crate::macros::*; use crate::{Admin, RequestHandler}; @@ -77,6 +76,7 @@ admin_endpoints![ RemoveBucketAlias, // Node operations + GetNodeInfo, CreateMetadataSnapshot, GetNodeStatistics, GetClusterStatistics, @@ -97,6 +97,7 @@ admin_endpoints![ local_admin_endpoints![ // Node operations + GetNodeInfo, CreateMetadataSnapshot, GetNodeStatistics, LaunchRepairOperation, @@ -157,11 +158,6 @@ pub struct GetClusterStatusRequest; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetClusterStatusResponse { - pub node: String, - pub garage_version: String, - pub garage_features: Option>, - pub rust_version: String, - pub db_engine: String, pub layout_version: u64, pub nodes: Vec, } @@ -636,6 +632,21 @@ pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); // Node operations // ********************************************** +// ---- GetNodeInfo ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LocalGetNodeInfoRequest; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct LocalGetNodeInfoResponse { + pub node_id: String, + pub garage_version: String, + pub garage_features: Option>, + pub rust_version: String, + pub db_engine: String, +} + // ---- CreateMetadataSnapshot ---- #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 37574dcf..0e6afce2 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -16,6 +16,7 @@ use opentelemetry_prometheus::PrometheusExporter; use garage_model::garage::Garage; use garage_rpc::{Endpoint as RpcEndpoint, *}; use garage_util::background::BackgroundRunner; +use garage_util::data::Uuid; use garage_util::error::Error as GarageError; use garage_util::socket_address::UnixOrTCPSocketAddress; @@ -265,3 +266,40 @@ fn verify_bearer_token(token: &hyper::http::HeaderValue, password_hash: &str) -> Ok(()) } + +pub(crate) fn find_matching_nodes(garage: &Garage, spec: &str) -> Result, Error> { + let mut res = vec![]; + if spec == "*" { + res = garage.system.cluster_layout().all_nodes().to_vec(); + for node in garage.system.get_known_nodes() { + if node.is_up && !res.contains(&node.id) { + res.push(node.id); + } + } + } else if spec == "self" { + res.push(garage.system.id); + } else { + let layout = garage.system.cluster_layout(); + let known_nodes = garage.system.get_known_nodes(); + let all_nodes = layout + .all_nodes() + .iter() + .copied() + .chain(known_nodes.iter().filter(|x| x.is_up).map(|x| x.id)); + for node in all_nodes { + if !res.contains(&node) && hex::encode(node).starts_with(spec) { + res.push(node); + } + } + if res.is_empty() { + return Err(Error::bad_request(format!("No nodes matching {}", spec))); + } + if res.len() > 1 { + return Err(Error::bad_request(format!( + "Multiple nodes matching {}: {:?}", + spec, res + ))); + } + } + Ok(res) +} diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index cb1fa493..13946e2b 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -105,12 +105,6 @@ impl RequestHandler for GetClusterStatusRequest { nodes.sort_by(|x, y| x.id.cmp(&y.id)); Ok(GetClusterStatusResponse { - node: hex::encode(garage.system.id), - garage_version: garage_util::version::garage_version().to_string(), - garage_features: garage_util::version::garage_features() - .map(|features| features.iter().map(ToString::to_string).collect()), - rust_version: garage_util::version::rust_version().to_string(), - db_engine: garage.db.engine(), layout_version: layout.current().version, nodes, }) diff --git a/src/api/admin/macros.rs b/src/api/admin/macros.rs index df2762fe..bf841295 100644 --- a/src/api/admin/macros.rs +++ b/src/api/admin/macros.rs @@ -136,20 +136,7 @@ macro_rules! local_admin_endpoints { type Response = [< $endpoint Response >]; async fn handle(self, garage: &Arc, admin: &Admin) -> Result { - let to = match self.node.as_str() { - "*" => garage.system.cluster_layout().all_nodes().to_vec(), - id => { - let nodes = garage.system.cluster_layout().all_nodes() - .iter() - .filter(|x| hex::encode(x).starts_with(id)) - .cloned() - .collect::>(); - if nodes.len() != 1 { - return Err(Error::bad_request(format!("Zero or multiple nodes matching {}: {:?}", id, nodes))); - } - nodes - } - }; + let to = find_matching_nodes(garage, self.node.as_str())?; let resps = garage.system.rpc_helper().call_many(&admin.endpoint, &to, diff --git a/src/api/admin/node.rs b/src/api/admin/node.rs index f6f43d95..3c7b5c03 100644 --- a/src/api/admin/node.rs +++ b/src/api/admin/node.rs @@ -18,6 +18,25 @@ use crate::api::*; use crate::error::Error; use crate::{Admin, RequestHandler}; +impl RequestHandler for LocalGetNodeInfoRequest { + type Response = LocalGetNodeInfoResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + Ok(LocalGetNodeInfoResponse { + node_id: hex::encode(garage.system.id), + garage_version: garage_util::version::garage_version().to_string(), + garage_features: garage_util::version::garage_features() + .map(|features| features.iter().map(ToString::to_string).collect()), + rust_version: garage_util::version::rust_version().to_string(), + db_engine: garage.db.engine(), + }) + } +} + impl RequestHandler for LocalCreateMetadataSnapshotRequest { type Response = LocalCreateMetadataSnapshotResponse; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 4d5c015e..2c2067dc 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -60,6 +60,7 @@ impl AdminApiRequest { POST AddBucketAlias (body), POST RemoveBucketAlias (body), // Node APIs + GET GetNodeInfo (default::body, query::node), POST CreateMetadataSnapshot (default::body, query::node), GET GetNodeStatistics (default::body, query::node), GET GetClusterStatistics (), @@ -93,9 +94,8 @@ impl AdminApiRequest { use router_v1::Endpoint; match v1_endpoint { - Endpoint::GetClusterStatus => { - Ok(AdminApiRequest::GetClusterStatus(GetClusterStatusRequest)) - } + // GetClusterStatus semantics changed: + // info about local node is no longer returned Endpoint::GetClusterHealth => { Ok(AdminApiRequest::GetClusterHealth(GetClusterHealthRequest)) } From ba68506c369f6ab3c65a6a07fb4c69cc8a4bdab0 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 11:51:48 +0100 Subject: [PATCH 055/192] admin api: generate openapi spec using utoipa (wip) --- Cargo.lock | 26 + Cargo.toml | 1 + doc/api/garage-admin-v2.html | 2 +- doc/api/garage-admin-v2.json | 1734 ++++++++++++++++++++++++++++++++++ doc/api/garage-admin-v2.yml | 1312 ------------------------- src/api/admin/Cargo.toml | 1 + src/api/admin/api.rs | 131 +-- src/api/admin/lib.rs | 1 + src/api/admin/openapi.rs | 483 ++++++++++ src/garage/Cargo.toml | 1 + src/garage/cli/structs.rs | 4 + src/garage/main.rs | 5 + 12 files changed, 2335 insertions(+), 1366 deletions(-) create mode 100644 doc/api/garage-admin-v2.json delete mode 100644 doc/api/garage-admin-v2.yml create mode 100644 src/api/admin/openapi.rs diff --git a/Cargo.lock b/Cargo.lock index b0ac9bf0..20820f7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1288,6 +1288,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "utoipa", ] [[package]] @@ -1318,6 +1319,7 @@ dependencies = [ "tokio", "tracing", "url", + "utoipa", ] [[package]] @@ -2272,6 +2274,7 @@ checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -4768,6 +4771,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap 2.7.1", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "uuid" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 4808cf1d..d1cae350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,7 @@ serde = { version = "1.0", default-features = false, features = ["derive", "rc"] serde_bytes = "0.11" serde_json = "1.0" toml = { version = "0.8", default-features = false, features = ["parse"] } +utoipa = "5.3.1" # newer version requires rust edition 2021 k8s-openapi = { version = "0.21", features = ["v1_24"] } diff --git a/doc/api/garage-admin-v2.html b/doc/api/garage-admin-v2.html index d93c2e7d..98f2ed7d 100644 --- a/doc/api/garage-admin-v2.html +++ b/doc/api/garage-admin-v2.html @@ -18,7 +18,7 @@ - + diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json new file mode 100644 index 00000000..5aa6bcb6 --- /dev/null +++ b/doc/api/garage-admin-v2.json @@ -0,0 +1,1734 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Garage administration API", + "description": "Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks.\n\n*Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!*", + "contact": { + "name": "The Garage team", + "url": "https://garagehq.deuxfleurs.fr/", + "email": "garagehq@deuxfleurs.fr" + }, + "license": { + "name": "AGPL-3.0", + "identifier": "AGPL-3.0" + }, + "version": "v2.0.0" + }, + "servers": [ + { + "url": "http://localhost:3903/", + "description": "A local server" + } + ], + "paths": { + "/v2/AddBucketAlias": { + "post": { + "tags": [ + "Alias" + ], + "description": "Add an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + "operationId": "AddBucketAlias", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBucketAliasRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Returns exhaustive information about the bucket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBucketAliasResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/AllowBucketKey": { + "post": { + "tags": [ + "Permission" + ], + "description": "\n⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious.\n\nAllows a key to do read/write/owner operations on a bucket.\n\nFlags in permissions which have the value true will be activated. Other flags will remain unchanged (ie. they will keep their internal value).\n\nFor example, if you set read to true, the key will be allowed to read the bucket.\nIf you set it to false, the key will keeps its previous read permission.\nIf you want to disallow read for the key, check the DenyBucketKey operation.\n ", + "operationId": "AllowBucketKey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllowBucketKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Returns exhaustive information about the bucket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllowBucketKeyResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/ApplyClusterLayout": { + "post": { + "tags": [ + "Layout" + ], + "description": "\nApplies to the cluster the layout changes currently registered as staged layout changes.\n\n*Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.*\n ", + "operationId": "ApplyClusterLayout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyClusterLayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The updated cluster layout has been applied in the cluster", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyClusterLayoutResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/CleanupIncompleteUploads": { + "post": { + "tags": [ + "Bucket" + ], + "description": "Removes all incomplete multipart uploads that are older than the specified number of seconds.", + "operationId": "CleanupIncompleteUploads", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupIncompleteUploadsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The bucket was cleaned up successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupIncompleteUploadsResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/ConnectClusterNodes": { + "post": { + "tags": [ + "Nodes" + ], + "description": "Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start.", + "operationId": "ConnectClusterNodes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectClusterNodesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The request has been handled correctly but it does not mean that all connection requests succeeded; some might have fail, you need to check the body!", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectClusterNodesResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/CreateBucket": { + "post": { + "tags": [ + "Bucket" + ], + "description": "\nCreates a new bucket, either with a global alias, a local one, or no alias at all.\nTechnically, you can also specify both `globalAlias` and `localAlias` and that would create two aliases.\n ", + "operationId": "CreateBucket", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBucketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Returns exhaustive information about the bucket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBucketResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/CreateKey": { + "post": { + "tags": [ + "Key" + ], + "description": "Creates a new API access key.", + "operationId": "CreateKey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Access key has been created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKeyResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/DeleteBucket": { + "post": { + "tags": [ + "Bucket" + ], + "description": "\nDeletes a storage bucket. A bucket cannot be deleted if it is not empty.\n\n**Warning:** this will delete all aliases associated with the bucket!\n ", + "operationId": "DeleteBucket", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the bucket to delete", + "required": true + } + ], + "responses": { + "200": { + "description": "Bucket has been deleted" + }, + "400": { + "description": "Bucket is not empty" + }, + "404": { + "description": "Bucket not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/DeleteKey": { + "post": { + "tags": [ + "Key" + ], + "description": "Delete a key from the cluster. Its access will be removed from all the buckets. Buckets are not automatically deleted and can be dangling. You should manually delete them before. ", + "operationId": "DeleteKey", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Access key ID", + "required": true + } + ], + "responses": { + "200": { + "description": "Access key has been deleted" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/DenyBucketKey": { + "post": { + "tags": [ + "Permission" + ], + "description": "\n⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious.\n\nDenies a key from doing read/write/owner operations on a bucket.\n\nFlags in permissions which have the value true will be deactivated. Other flags will remain unchanged.\n\nFor example, if you set read to true, the key will be denied from reading.\nIf you set read to false, the key will keep its previous permissions.\nIf you want the key to have the reading permission, check the AllowBucketKey operation.\n ", + "operationId": "DenyBucketKey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DenyBucketKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Returns exhaustive information about the bucket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DenyBucketKeyResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetBucketInfo": { + "get": { + "tags": [ + "Bucket" + ], + "description": "\nGiven a bucket identifier (`id`) or a global alias (`alias`), get its information.\nIt includes its aliases, its web configuration, keys that have some permissions\non it, some statistics (number of objects, size), number of dangling multipart uploads,\nand its quotas (if any).\n ", + "operationId": "GetBucketInfo", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Exact bucket ID to look up", + "required": true + }, + { + "name": "globalAlias", + "in": "path", + "description": "Global alias of bucket to look up", + "required": true + }, + { + "name": "search", + "in": "path", + "description": "Partial ID or alias to search for", + "required": true + } + ], + "responses": { + "200": { + "description": "Returns exhaustive information about the bucket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetClusterHealth": { + "get": { + "tags": [ + "Nodes" + ], + "description": "Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total).", + "operationId": "GetClusterHealth", + "responses": { + "200": { + "description": "Cluster health report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetClusterHealthResponse" + } + } + } + } + } + } + }, + "/v2/GetClusterLayout": { + "get": { + "tags": [ + "Layout" + ], + "description": "\nReturns the cluster's current layout, including:\n\n- Currently configured cluster layout\n- Staged changes to the cluster layout\n\n*Capacity is given in bytes*\n*The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.*\n ", + "operationId": "GetClusterLayout", + "responses": { + "200": { + "description": "Current cluster layout", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetClusterLayoutResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetClusterStatus": { + "get": { + "tags": [ + "Nodes" + ], + "description": "\nReturns the cluster's current status, including:\n\n- ID of the node being queried and its version of the Garage daemon\n- Live nodes\n- Currently configured cluster layout\n- Staged changes to the cluster layout\n\n*Capacity is given in bytes*\n ", + "operationId": "GetClusterStatus", + "responses": { + "200": { + "description": "Cluster status report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetClusterStatusResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetKeyInfo": { + "get": { + "tags": [ + "Key" + ], + "description": "\nReturn information about a specific key like its identifiers, its permissions and buckets on which it has permissions.\nYou can search by specifying the exact key identifier (`id`) or by specifying a pattern (`search`).\n\nFor confidentiality reasons, the secret key is not returned by default: you must pass the `showSecretKey` query parameter to get it.\n ", + "operationId": "GetKeyInfo", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Access key ID", + "required": true + }, + { + "name": "search", + "in": "path", + "description": "Partial key ID or name to search for", + "required": true + }, + { + "name": "showSecretKey", + "in": "path", + "description": "Whether to return the secret access key", + "required": true + } + ], + "responses": { + "200": { + "description": "Information about the access key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetKeyInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/ImportKey": { + "post": { + "tags": [ + "Key" + ], + "description": "\nImports an existing API key. This feature must only be used for migrations and backup restore.\n\n**Do not use it to generate custom key identifiers or you will break your Garage cluster.**\n ", + "operationId": "ImportKey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Access key has been imported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportKeyResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/ListBuckets": { + "get": { + "tags": [ + "Bucket" + ], + "description": "List all the buckets on the cluster with their UUID and their global and local aliases.", + "operationId": "ListBuckets", + "responses": { + "200": { + "description": "Returns the UUID of all the buckets and all their aliases", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBucketsResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/ListKeys": { + "get": { + "tags": [ + "Key" + ], + "description": "Returns all API access keys in the cluster.", + "operationId": "ListKeys", + "responses": { + "200": { + "description": "Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListKeysResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/RemoveBucketAlias": { + "post": { + "tags": [ + "Alias" + ], + "description": "Remove an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + "operationId": "RemoveBucketAlias", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveBucketAliasRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Returns exhaustive information about the bucket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveBucketAliasResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/RevertClusterLayout": { + "post": { + "tags": [ + "Layout" + ], + "description": "Clear staged layout", + "operationId": "RevertClusterLayout", + "responses": { + "200": { + "description": "All pending changes to the cluster layout have been erased", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertClusterLayoutResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/UpdateBucket": { + "post": { + "tags": [ + "Bucket" + ], + "description": "\nAll fields (`websiteAccess` and `quotas`) are optional.\nIf they are present, the corresponding modifications are applied to the bucket, otherwise nothing is changed.\n\nIn `websiteAccess`: if `enabled` is `true`, `indexDocument` must be specified.\nThe field `errorDocument` is optional, if no error document is set a generic\nerror message is displayed when errors happen. Conversely, if `enabled` is\n`false`, neither `indexDocument` nor `errorDocument` must be specified.\n\nIn `quotas`: new values of `maxSize` and `maxObjects` must both be specified, or set to `null`\nto remove the quotas. An absent value will be considered the same as a `null`. It is not possible\nto change only one of the two quotas.\n ", + "operationId": "UpdateBucket", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the bucket to update", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBucketRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Bucket has been updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBucketResponse" + } + } + } + }, + "404": { + "description": "Bucket not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/UpdateClusterLayout": { + "post": { + "tags": [ + "Layout" + ], + "description": "\nSend modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /GetClusterHealth`. Once the set of staged changes is satisfactory, the user may call `POST /ApplyClusterLayout` to apply the changed changes, or `POST /RevertClusterLayout` to clear all of the staged changes in the layout.\n\nSetting the capacity to `null` will configure the node as a gateway.\nOtherwise, capacity must be now set in bytes (before Garage 0.9 it was arbitrary weights).\nFor example to declare 100GB, you must set `capacity: 100000000000`.\n\nGarage uses internally the International System of Units (SI), it assumes that 1kB = 1000 bytes, and displays storage as kB, MB, GB (and not KiB, MiB, GiB that assume 1KiB = 1024 bytes).\n ", + "operationId": "UpdateClusterLayout", + "requestBody": { + "description": "\nTo add a new node to the layout or to change the configuration of an existing node, simply set the values you want (`zone`, `capacity`, and `tags`).\nTo remove a node, simply pass the `remove: true` field.\nThis logic is represented in OpenAPI with a 'One Of' object.\n\nContrary to the CLI that may update only a subset of the fields capacity, zone and tags, when calling this API all of these values must be specified.\n ", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateClusterLayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proposed changes have been added to the list of pending changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateClusterLayoutResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/UpdateKey": { + "post": { + "tags": [ + "Key" + ], + "description": "\nUpdates information about the specified API access key.\n\n*Note: the secret key is not returned in the response, `null` is sent instead.*\n ", + "operationId": "UpdateKey", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Access key ID", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKeyRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Access key has been updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKeyResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + } + }, + "components": { + "schemas": { + "AddBucketAliasRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/BucketAliasEnum" + }, + { + "type": "object", + "required": [ + "bucketId" + ], + "properties": { + "bucketId": { + "type": "string" + } + } + } + ] + }, + "AddBucketAliasResponse": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + }, + "AllowBucketKeyRequest": { + "$ref": "#/components/schemas/BucketKeyPermChangeRequest" + }, + "AllowBucketKeyResponse": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + }, + "ApiBucketKeyPerm": { + "type": "object", + "properties": { + "owner": { + "type": "boolean" + }, + "read": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + } + }, + "ApiBucketQuotas": { + "type": "object", + "properties": { + "maxObjects": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "maxSize": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "ApplyClusterLayoutRequest": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ApplyClusterLayoutResponse": { + "type": "object", + "required": [ + "message", + "layout" + ], + "properties": { + "layout": { + "$ref": "#/components/schemas/GetClusterLayoutResponse" + }, + "message": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "BucketAliasEnum": { + "oneOf": [ + { + "type": "object", + "required": [ + "globalAlias" + ], + "properties": { + "globalAlias": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "localAlias", + "accessKeyId" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "localAlias": { + "type": "string" + } + } + } + ] + }, + "BucketKeyPermChangeRequest": { + "type": "object", + "required": [ + "bucketId", + "accessKeyId", + "permissions" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "bucketId": { + "type": "string" + }, + "permissions": { + "$ref": "#/components/schemas/ApiBucketKeyPerm" + } + } + }, + "BucketLocalAlias": { + "type": "object", + "required": [ + "accessKeyId", + "alias" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "alias": { + "type": "string" + } + } + }, + "CleanupIncompleteUploadsRequest": { + "type": "object", + "required": [ + "bucket_id", + "older_than_secs" + ], + "properties": { + "bucket_id": { + "type": "string" + }, + "older_than_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "CleanupIncompleteUploadsResponse": { + "type": "object", + "required": [ + "uploads_deleted" + ], + "properties": { + "uploads_deleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ConnectClusterNodesRequest": { + "type": "array", + "items": { + "type": "string" + } + }, + "ConnectClusterNodesResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConnectNodeResponse" + } + }, + "ConnectNodeResponse": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + } + } + }, + "CreateBucketLocalAlias": { + "type": "object", + "required": [ + "accessKeyId", + "alias" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "allow": { + "$ref": "#/components/schemas/ApiBucketKeyPerm" + } + } + }, + "CreateBucketRequest": { + "type": "object", + "properties": { + "globalAlias": { + "type": [ + "string", + "null" + ] + }, + "localAlias": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CreateBucketLocalAlias" + } + ] + } + } + }, + "CreateBucketResponse": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + }, + "CreateKeyRequest": { + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + } + } + }, + "CreateKeyResponse": { + "$ref": "#/components/schemas/GetKeyInfoResponse" + }, + "DenyBucketKeyRequest": { + "$ref": "#/components/schemas/BucketKeyPermChangeRequest" + }, + "DenyBucketKeyResponse": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + }, + "FreeSpaceResp": { + "type": "object", + "required": [ + "available", + "total" + ], + "properties": { + "available": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "GetBucketInfoKey": { + "type": "object", + "required": [ + "accessKeyId", + "name", + "permissions", + "bucketLocalAliases" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "bucketLocalAliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "permissions": { + "$ref": "#/components/schemas/ApiBucketKeyPerm" + } + } + }, + "GetBucketInfoResponse": { + "type": "object", + "required": [ + "id", + "globalAliases", + "websiteAccess", + "keys", + "objects", + "bytes", + "unfinishedUploads", + "unfinishedMultipartUploads", + "unfinishedMultipartUploadParts", + "unfinishedMultipartUploadBytes", + "quotas" + ], + "properties": { + "bytes": { + "type": "integer", + "format": "int64", + "description": "Total number of bytes used by objects in this bucket" + }, + "globalAliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of global aliases for this bucket" + }, + "id": { + "type": "string", + "description": "Identifier of the bucket" + }, + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetBucketInfoKey" + }, + "description": "List of access keys that have permissions granted on this bucket" + }, + "objects": { + "type": "integer", + "format": "int64", + "description": "Number of objects in this bucket" + }, + "quotas": { + "$ref": "#/components/schemas/ApiBucketQuotas", + "description": "Quotas that apply to this bucket" + }, + "unfinishedMultipartUploadBytes": { + "type": "integer", + "format": "int64", + "description": "Total number of bytes used by unfinished multipart uploads in this bucket" + }, + "unfinishedMultipartUploadParts": { + "type": "integer", + "format": "int64", + "description": "Number of parts in unfinished multipart uploads in this bucket" + }, + "unfinishedMultipartUploads": { + "type": "integer", + "format": "int64", + "description": "Number of unfinished multipart uploads in this bucket" + }, + "unfinishedUploads": { + "type": "integer", + "format": "int64", + "description": "Number of unfinished uploads in this bucket" + }, + "websiteAccess": { + "type": "boolean", + "description": "Whether website acces is enabled for this bucket" + }, + "websiteConfig": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GetBucketInfoWebsiteResponse", + "description": "Website configuration for this bucket" + } + ] + } + } + }, + "GetBucketInfoWebsiteResponse": { + "type": "object", + "required": [ + "indexDocument" + ], + "properties": { + "errorDocument": { + "type": [ + "string", + "null" + ] + }, + "indexDocument": { + "type": "string" + } + } + }, + "GetClusterHealthResponse": { + "type": "object", + "required": [ + "status", + "knownNodes", + "connectedNodes", + "storageNodes", + "storageNodesOk", + "partitions", + "partitionsQuorum", + "partitionsAllOk" + ], + "properties": { + "connectedNodes": { + "type": "integer", + "description": "the nubmer of nodes this Garage node currently has an open connection to", + "minimum": 0 + }, + "knownNodes": { + "type": "integer", + "description": "the number of nodes this Garage node has had a TCP connection to since the daemon started", + "minimum": 0 + }, + "partitions": { + "type": "integer", + "description": "the total number of partitions of the data (currently always 256)", + "minimum": 0 + }, + "partitionsAllOk": { + "type": "integer", + "description": "the number of partitions for which we are connected to all storage nodes responsible of storing it", + "minimum": 0 + }, + "partitionsQuorum": { + "type": "integer", + "description": "the number of partitions for which a quorum of write nodes is available", + "minimum": 0 + }, + "status": { + "type": "string", + "description": "One of `healthy`, `degraded` or `unavailable`:\n- healthy: Garage node is connected to all storage nodes\n- degraded: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions\n- unavailable: a quorum of write nodes is not available for some partitions" + }, + "storageNodes": { + "type": "integer", + "description": "the number of storage nodes currently registered in the cluster layout", + "minimum": 0 + }, + "storageNodesOk": { + "type": "integer", + "description": "the number of storage nodes to which a connection is currently open", + "minimum": 0 + } + } + }, + "GetClusterLayoutResponse": { + "type": "object", + "required": [ + "version", + "roles", + "stagedRoleChanges" + ], + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeRoleResp" + } + }, + "stagedRoleChanges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeRoleChange" + } + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "GetClusterStatusResponse": { + "type": "object", + "required": [ + "layoutVersion", + "nodes" + ], + "properties": { + "layoutVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeResp" + } + } + } + }, + "GetKeyInfoResponse": { + "type": "object", + "required": [ + "name", + "accessKeyId", + "permissions", + "buckets" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyInfoBucketResponse" + } + }, + "name": { + "type": "string" + }, + "permissions": { + "$ref": "#/components/schemas/KeyPerm" + }, + "secretAccessKey": { + "type": [ + "string", + "null" + ] + } + } + }, + "ImportKeyRequest": { + "type": "object", + "required": [ + "accessKeyId", + "secretAccessKey" + ], + "properties": { + "accessKeyId": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "secretAccessKey": { + "type": "string" + } + } + }, + "ImportKeyResponse": { + "$ref": "#/components/schemas/GetKeyInfoResponse" + }, + "KeyInfoBucketResponse": { + "type": "object", + "required": [ + "id", + "globalAliases", + "localAliases", + "permissions" + ], + "properties": { + "globalAliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "localAliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "$ref": "#/components/schemas/ApiBucketKeyPerm" + } + } + }, + "KeyPerm": { + "type": "object", + "properties": { + "createBucket": { + "type": "boolean" + } + } + }, + "ListBucketsResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListBucketsResponseItem" + } + }, + "ListBucketsResponseItem": { + "type": "object", + "required": [ + "id", + "globalAliases", + "localAliases" + ], + "properties": { + "globalAliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "localAliases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BucketLocalAlias" + } + } + } + }, + "ListKeysResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListKeysResponseItem" + } + }, + "ListKeysResponseItem": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "NodeResp": { + "type": "object", + "required": [ + "id", + "isUp", + "draining" + ], + "properties": { + "addr": { + "type": [ + "string", + "null" + ] + }, + "dataPartition": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FreeSpaceResp" + } + ] + }, + "draining": { + "type": "boolean" + }, + "hostname": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "isUp": { + "type": "boolean" + }, + "lastSeenSecsAgo": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "metadataPartition": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FreeSpaceResp" + } + ] + }, + "role": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NodeRoleResp" + } + ] + } + } + }, + "NodeRoleChange": { + "allOf": [ + { + "$ref": "#/components/schemas/NodeRoleChangeEnum" + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + } + ] + }, + "NodeRoleChangeEnum": { + "oneOf": [ + { + "type": "object", + "required": [ + "remove" + ], + "properties": { + "remove": { + "type": "boolean" + } + } + }, + { + "type": "object", + "required": [ + "zone", + "tags" + ], + "properties": { + "capacity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "zone": { + "type": "string" + } + } + } + ] + }, + "NodeRoleResp": { + "type": "object", + "required": [ + "id", + "zone", + "tags" + ], + "properties": { + "capacity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "zone": { + "type": "string" + } + } + }, + "RemoveBucketAliasRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/BucketAliasEnum" + }, + { + "type": "object", + "required": [ + "bucketId" + ], + "properties": { + "bucketId": { + "type": "string" + } + } + } + ] + }, + "RemoveBucketAliasResponse": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + }, + "RevertClusterLayoutResponse": { + "$ref": "#/components/schemas/GetClusterLayoutResponse" + }, + "UpdateBucketRequestBody": { + "type": "object", + "properties": { + "quotas": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ApiBucketQuotas" + } + ] + }, + "websiteAccess": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateBucketWebsiteAccess" + } + ] + } + } + }, + "UpdateBucketResponse": { + "$ref": "#/components/schemas/GetBucketInfoResponse" + }, + "UpdateBucketWebsiteAccess": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "errorDocument": { + "type": [ + "string", + "null" + ] + }, + "indexDocument": { + "type": [ + "string", + "null" + ] + } + } + }, + "UpdateClusterLayoutRequest": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeRoleChange" + } + }, + "UpdateClusterLayoutResponse": { + "$ref": "#/components/schemas/GetClusterLayoutResponse" + }, + "UpdateKeyRequestBody": { + "type": "object", + "properties": { + "allow": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KeyPerm" + } + ] + }, + "deny": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KeyPerm" + } + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + } + }, + "UpdateKeyResponse": { + "$ref": "#/components/schemas/GetKeyInfoResponse" + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] +} diff --git a/doc/api/garage-admin-v2.yml b/doc/api/garage-admin-v2.yml deleted file mode 100644 index 739b4166..00000000 --- a/doc/api/garage-admin-v2.yml +++ /dev/null @@ -1,1312 +0,0 @@ -openapi: 3.0.0 -info: - version: v2.0.0 - title: Garage Administration API v0+garage-v2.0.0 - description: | - Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks. - - *Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!* -paths: - /GetClusterHealth: - get: - tags: - - Nodes - operationId: "GetClusterHealth" - summary: "Cluster health report" - description: | - Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total). - responses: - '500': - description: | - The server can not answer your request because it is in a bad state - '200': - description: | - Information about the queried node, its environment and the current layout - content: - application/json: - schema: - type: object - required: [ status, knownNodes, connectedNodes, storageNodes, storageNodesOk, partitions, partitionsQuorum, partitionsAllOk ] - properties: - status: - type: string - example: "healthy" - knownNodes: - type: integer - format: int64 - example: 4 - connectedNodes: - type: integer - format: int64 - example: 4 - storageNodes: - type: integer - format: int64 - example: 3 - storageNodesOk: - type: integer - format: int64 - example: 3 - partitions: - type: integer - format: int64 - example: 256 - partitionsQuorum: - type: integer - format: int64 - example: 256 - partitionsAllOk: - type: integer - format: int64 - example: 256 - /GetClusterStatus: - get: - tags: - - Nodes - operationId: "GetClusterStatus" - summary: "Describe cluster" - description: | - Returns the cluster's current status, including: - - ID of the node being queried and its version of the Garage daemon - - Live nodes - - Currently configured cluster layout - - Staged changes to the cluster layout - - *Capacity is given in bytes* - responses: - '500': - description: | - The server can not answer your request because it is in a bad state - '200': - description: | - Information about the queried node, its environment and the current layout - content: - application/json: - schema: - type: object - required: [ layoutVersion, nodes ] - properties: - layoutVersion: - type: integer - example: 1 - nodes: - type: array - example: - - id: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" - addr: "10.0.0.11:3901" - isUp: true - lastSeenSecsAgo: 9 - hostname: orion - - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" - addr: "10.0.0.12:3901" - isUp: true - lastSeenSecsAgo: 13 - hostname: pegasus - - id: "e2ee7984ee65b260682086ec70026165903c86e601a4a5a501c1900afe28d84b" - addr: "10.0.0.13:3901" - isUp: true - lastSeenSecsAgo: 2 - hostname: neptune - items: - $ref: '#/components/schemas/NodeNetworkInfo' - - /ConnectClusterNodes: - post: - tags: - - Nodes - operationId: "ConnectClusterNodes" - summary: "Connect a new node" - description: | - Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start. - requestBody: - required: true - content: - application/json: - schema: - type: array - example: - - "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f@10.0.0.11:3901" - - "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff@10.0.0.12:3901" - items: - type: string - - responses: - '500': - description: | - The server can not answer your request because it is in a bad state - '400': - description: | - Your request is malformed, check your JSON - '200': - description: | - The request has been handled correctly but it does not mean that all connection requests succeeded; some might have fail, you need to check the body! - content: - application/json: - schema: - type: array - example: - - success: true - error: - - success: false - error: "Handshake error" - items: - type: object - properties: - success: - type: boolean - example: true - error: - type: string - nullable: true - example: null - - /GetClusterLayout: - get: - tags: - - Layout - operationId: "GetClusterLayout" - summary: "Details on the current and staged layout" - description: | - Returns the cluster's current layout, including: - - Currently configured cluster layout - - Staged changes to the cluster layout - - *Capacity is given in bytes* - *The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.* - responses: - '500': - description: | - The server can not answer your request because it is in a bad state - '200': - description: | - Returns the cluster's current cluster layout: - - Currently configured cluster layout - - Staged changes to the cluster layout - content: - application/json: - schema: - $ref: '#/components/schemas/ClusterLayout' - - /UpdateClusterLayout: - post: - tags: - - Layout - operationId: "UpdateClusterLayout" - summary: "Send modifications to the cluster layout" - description: | - Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /GetClusterHealth`. Once the set of staged changes is satisfactory, the user may call `POST /ApplyClusterLayout` to apply the changed changes, or `POST /RevertClusterLayout` to clear all of the staged changes in the layout. - - Setting the capacity to `null` will configure the node as a gateway. - Otherwise, capacity must be now set in bytes (before Garage 0.9 it was arbitrary weights). - For example to declare 100GB, you must set `capacity: 100000000000`. - - Garage uses internally the International System of Units (SI), it assumes that 1kB = 1000 bytes, and displays storage as kB, MB, GB (and not KiB, MiB, GiB that assume 1KiB = 1024 bytes). - requestBody: - description: | - To add a new node to the layout or to change the configuration of an existing node, simply set the values you want (`zone`, `capacity`, and `tags`). - To remove a node, simply pass the `remove: true` field. - This logic is represented in OpenAPI with a "One Of" object. - - Contrary to the CLI that may update only a subset of the fields capacity, zone and tags, when calling this API all of these values must be specified. - required: true - content: - application/json: - schema: - type: array - example: - - id: "e2ee7984ee65b260682086ec70026165903c86e601a4a5a501c1900afe28d84b" - zone: "geneva" - capacity: 100000000000 - tags: - - gateway - - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" - remove: true - items: - $ref: '#/components/schemas/NodeRoleChange' - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Invalid syntax or requested change" - '200': - description: "The layout modification has been correctly staged" - content: - application/json: - schema: - $ref: '#/components/schemas/ClusterLayout' - - /ApplyClusterLayout: - post: - tags: - - Layout - operationId: "ApplyClusterLayout" - summary: "Apply staged layout" - description: | - Applies to the cluster the layout changes currently registered as staged layout changes. - - *Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.* - requestBody: - description: | - Similarly to the CLI, the body must include the version of the new layout that will be created, which MUST be 1 + the value of the currently existing layout in the cluster. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LayoutVersion' - - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Invalid syntax or requested change" - '200': - description: "The staged layout has been applied as the new layout of the cluster, a rebalance has been triggered." - content: - application/json: - schema: - type: object - required: [ message, layout ] - properties: - message: - type: array - items: - type: string - example: - - "==== COMPUTATION OF A NEW PARTITION ASSIGNATION ====" - - "" - - "Partitions are replicated 1 times on at least 1 distinct zones." - - "" - - "Optimal partition size: 419.4 MB (3 B in previous layout)" - - "Usable capacity / total cluster capacity: 107.4 GB / 107.4 GB (100.0 %)" - - "Effective capacity (replication factor 1): 107.4 GB" - - "" - - "A total of 0 new copies of partitions need to be transferred." - - "" - - "dc1 Tags Partitions Capacity Usable capacity\n 6a8e08af2aab1083 a,v 256 (0 new) 107.4 GB 107.4 GB (100.0%)\n TOTAL 256 (256 unique) 107.4 GB 107.4 GB (100.0%)\n\n" - layout: - $ref: '#/components/schemas/ClusterLayout' - - - /RevertClusterLayout: - post: - tags: - - Layout - operationId: "RevertClusterLayout" - summary: "Clear staged layout" - description: | - Clears all of the staged layout changes. - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Invalid syntax or requested change" - '200': - description: "The staged layout has been cleared, you can start again sending modification from a fresh copy with `POST /UpdateClusterLayout`." - - /ListKeys: - get: - tags: - - Key - operationId: "ListKeys" - summary: "List all keys" - description: | - Returns all API access keys in the cluster. - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '200': - description: | - Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string) - content: - application/json: - schema: - type: array - example: - - id: "GK31c2f218a2e44f485b94239e" - name: "test-key" - - id: "GKe10061ac9c2921f09e4c5540" - name: "" - items: - type: object - required: [ id ] - properties: - id: - type: string - name: - type: string - - /CreateKey: - post: - tags: - - Key - operationId: "CreateKey" - summary: "Create a new API key" - description: | - Creates a new API access key. - requestBody: - description: | - You can set a friendly name for this key. - If you don't want to, you can set the name to `null`. - - *Note: the secret key is returned in the response.* - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - nullable: true - example: "test-key" - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Invalid syntax or requested change" - '200': - description: "The key has been added" - content: - application/json: - schema: - $ref: '#/components/schemas/KeyInfo' - - /GetKeyInfo: - get: - tags: - - Key - operationId: "GetKeyInfo" - summary: "Get key information" - description: | - Return information about a specific key like its identifiers, its permissions and buckets on which it has permissions. - You can search by specifying the exact key identifier (`id`) or by specifying a pattern (`search`). - - For confidentiality reasons, the secret key is not returned by default: you must pass the `showSecretKey` query parameter to get it. - parameters: - - name: id - in: query - description: | - The exact API access key generated by Garage. - - Incompatible with `search`. - example: "GK31c2f218a2e44f485b94239e" - schema: - type: string - - name: search - in: query - description: | - A pattern (beginning or full string) corresponding to a key identifier or friendly name. - - Incompatible with `id`. - example: "test-k" - schema: - type: string - - name: showSecretKey - in: query - schema: - type: string - default: "false" - enum: - - "false" - - "true" - example: "false" - required: false - description: "Wether or not the secret key should be returned in the response" - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '200': - description: | - Returns information about the key - content: - application/json: - schema: - $ref: '#/components/schemas/KeyInfo' - - /DeleteKey: - post: - tags: - - Key - operationId: "DeleteKey" - summary: "Delete a key" - description: | - Delete a key from the cluster. Its access will be removed from all the buckets. Buckets are not automatically deleted and can be dangling. You should manually delete them before. - parameters: - - name: id - in: query - required: true - description: "The exact API access key generated by Garage" - example: "GK31c2f218a2e44f485b94239e" - schema: - type: string - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '200': - description: "The key has been deleted" - - - /UpdateKey: - post: - tags: - - Key - operationId: "UpdateKey" - summary: "Update a key" - description: | - Updates information about the specified API access key. - - *Note: the secret key is not returned in the response, `null` is sent instead.* - parameters: - - name: id - in: query - required: true - description: "The exact API access key generated by Garage" - example: "GK31c2f218a2e44f485b94239e" - schema: - type: string - requestBody: - description: | - For a given key, provide a first set with the permissions to grant, and a second set with the permissions to remove - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - example: "test-key" - allow: - type: object - example: - properties: - createBucket: - type: boolean - example: true - deny: - type: object - properties: - createBucket: - type: boolean - example: true - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Invalid syntax or requested change" - '200': - description: | - Returns information about the key - content: - application/json: - schema: - $ref: '#/components/schemas/KeyInfo' - - - /ImportKey: - post: - tags: - - Key - operationId: "ImportKey" - summary: "Import an existing key" - description: | - Imports an existing API key. This feature must only be used for migrations and backup restore. - - **Do not use it to generate custom key identifiers or you will break your Garage cluster.** - requestBody: - description: | - Information on the key to import - required: true - content: - application/json: - schema: - type: object - required: [ name, accessKeyId, secretAccessKey ] - properties: - name: - type: string - example: "test-key" - nullable: true - accessKeyId: - type: string - example: "GK31c2f218a2e44f485b94239e" - secretAccessKey: - type: string - example: "b892c0665f0ada8a4755dae98baa3b133590e11dae3bcc1f9d769d67f16c3835" - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Invalid syntax or requested change" - '200': - description: "The key has been imported into the system" - content: - application/json: - schema: - $ref: '#/components/schemas/KeyInfo' - - /ListBuckets: - get: - tags: - - Bucket - operationId: "ListBuckets" - summary: "List all buckets" - description: | - List all the buckets on the cluster with their UUID and their global and local aliases. - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '200': - description: | - Returns the UUID of the bucket and all its aliases - content: - application/json: - schema: - type: array - example: - - id: "70dc3bed7fe83a75e46b66e7ddef7d56e65f3c02f9f80b6749fb97eccb5e1033" - globalAliases: - - "container_registry" - - id: "96470e0df00ec28807138daf01915cfda2bee8eccc91dea9558c0b4855b5bf95" - localAliases: - - alias: "my_documents" - accessKeyid: "GK31c2f218a2e44f485b94239e" - - id: "d7452a935e663fc1914f3a5515163a6d3724010ce8dfd9e4743ca8be5974f995" - globalAliases: - - "example.com" - - "www.example.com" - localAliases: - - alias: "corp_website" - accessKeyId: "GKe10061ac9c2921f09e4c5540" - - alias: "web" - accessKeyid: "GK31c2f218a2e44f485b94239e" - - id: "" - items: - type: object - required: [ id ] - properties: - id: - type: string - globalAliases: - type: array - items: - type: string - localAliases: - type: array - items: - type: object - required: [ alias, accessKeyId ] - properties: - alias: - type: string - accessKeyId: - type: string - - /CreateBucket: - post: - tags: - - Bucket - operationId: "CreateBucket" - summary: "Create a bucket" - description: | - Creates a new bucket, either with a global alias, a local one, or no alias at all. - Technically, you can also specify both `globalAlias` and `localAlias` and that would create two aliases. - requestBody: - description: | - Aliases to put on the new bucket - required: true - content: - application/json: - schema: - type: object - properties: - globalAlias: - type: string - example: "my_documents" - localAlias: - type: object - properties: - accessKeyId: - type: string - alias: - type: string - allow: - type: object - properties: - read: - type: boolean - example: true - write: - type: boolean - example: true - owner: - type: boolean - example: true - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "The payload is not formatted correctly" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /GetBucketInfo: - get: - tags: - - Bucket - operationId: "GetBucketInfo" - summary: "Get a bucket" - description: | - Given a bucket identifier (`id`) or a global alias (`alias`), get its information. - It includes its aliases, its web configuration, keys that have some permissions - on it, some statistics (number of objects, size), number of dangling multipart uploads, - and its quotas (if any). - parameters: - - name: id - in: query - description: | - The exact bucket identifier, a 32 bytes hexadecimal string. - - Incompatible with `alias`. - example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87" - schema: - type: string - - name: alias - in: query - description: | - The exact global alias of one of the existing buckets. - - Incompatible with `id`. - example: "my_documents" - schema: - type: string - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - - /DeleteBucket: - post: - tags: - - Bucket - operationId: "DeleteBucket" - summary: "Delete a bucket" - description: | - Delete a bucket.Deletes a storage bucket. A bucket cannot be deleted if it is not empty. - - **Warning:** this will delete all aliases associated with the bucket! - parameters: - - name: id - in: query - required: true - description: "The exact bucket identifier, a 32 bytes hexadecimal string" - example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87" - schema: - type: string - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bucket is not empty" - '404': - description: "Bucket not found" - '200': - description: Bucket has been deleted - - - - /UpdateBucket: - post: - tags: - - Bucket - operationId: "UpdateBucket" - summary: "Update a bucket" - description: | - All fields (`websiteAccess` and `quotas`) are optional. - If they are present, the corresponding modifications are applied to the bucket, otherwise nothing is changed. - - In `websiteAccess`: if `enabled` is `true`, `indexDocument` must be specified. - The field `errorDocument` is optional, if no error document is set a generic - error message is displayed when errors happen. Conversely, if `enabled` is - `false`, neither `indexDocument` nor `errorDocument` must be specified. - - In `quotas`: new values of `maxSize` and `maxObjects` must both be specified, or set to `null` - to remove the quotas. An absent value will be considered the same as a `null`. It is not possible - to change only one of the two quotas. - parameters: - - name: id - in: query - required: true - description: "The exact bucket identifier, a 32 bytes hexadecimal string" - example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87" - schema: - type: string - requestBody: - description: | - Requested changes on the bucket. Both root fields are optionals. - required: true - content: - application/json: - schema: - type: object - properties: - websiteAccess: - type: object - properties: - enabled: - type: boolean - example: true - indexDocument: - type: string - example: "index.html" - errorDocument: - type: string - example: "error/400.html" - quotas: - type: object - properties: - maxSize: - type: integer - format: int64 - nullable: true - example: 19029801 - maxObjects: - type: integer - format: int64 - nullable: true - example: null - - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your body." - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /CleanupIncompleteUploads: - post: - tags: - - Bucket - operationId: "CleanupIncompleteUploads" - summary: "Cleanup incomplete uploads in a bucket" - description: | - Cleanup all incomplete uploads in a bucket that are older than a specified number of seconds - requestBody: - description: | - Bucket id and minimum age of uploads to delete (in seconds) - required: true - content: - application/json: - schema: - type: object - required: [bucketId, olderThanSecs] - properties: - bucketId: - type: string - example: "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b" - olderThanSecs: - type: integer - example: "3600" - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "The payload is not formatted correctly" - '200': - description: "The bucket was cleaned up successfully" - content: - application/json: - schema: - type: object - properties: - uploadsDeleted: - type: integer - example: 12 - - /AllowBucketKey: - post: - tags: - - Permissions - operationId: "AllowBucketKey" - summary: "Allow key" - description: | - ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. - - Allows a key to do read/write/owner operations on a bucket. - - Flags in permissions which have the value true will be activated. Other flags will remain unchanged (ie. they will keep their internal value). - - For example, if you set read to true, the key will be allowed to read the bucket. - If you set it to false, the key will keeps its previous read permission. - If you want to disallow read for the key, check the DenyBucketKey operation. - - requestBody: - description: | - Aliases to put on the new bucket - required: true - content: - application/json: - schema: - type: object - required: [ bucketId, accessKeyId, permissions ] - properties: - bucketId: - type: string - example: "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b" - accessKeyId: - type: string - example: "GK31c2f218a2e44f485b94239e" - permissions: - type: object - required: [ read, write, owner ] - properties: - read: - type: boolean - example: true - write: - type: boolean - example: true - owner: - type: boolean - example: true - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your request body" - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /DenyBucketKey: - post: - tags: - - Permissions - operationId: "DenyBucketKey" - summary: "Deny key" - description: | - ⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. - - Denies a key from doing read/write/owner operations on a bucket. - - Flags in permissions which have the value true will be deactivated. Other flags will remain unchanged. - - For example, if you set read to true, the key will be denied from reading. - If you set read to false, the key will keep its previous permissions. - If you want the key to have the reading permission, check the AllowBucketKey operation. - - requestBody: - description: | - Aliases to put on the new bucket - required: true - content: - application/json: - schema: - type: object - required: [ bucketId, accessKeyId, permissions ] - properties: - bucketId: - type: string - example: "e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b" - accessKeyId: - type: string - example: "GK31c2f218a2e44f485b94239e" - permissions: - type: object - required: [ read, write, owner ] - properties: - read: - type: boolean - example: true - write: - type: boolean - example: true - owner: - type: boolean - example: true - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your request body" - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /AddBucketAlias: - post: - tags: - - Bucket aliases - operationId: "AddBucketAlias" - summary: "Add an alias to a bucket" - description: | - Add an alias for the target bucket. - This can be a local alias if `accessKeyId` is specified, - or a global alias otherwise. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [bucketId] - properties: - bucketId: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - globalAlias: - type: string - localAlias: - type: string - example: my_documents - accessKeyId: - type: string - example: GK31c2f218a2e44f485b94239e - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your request body" - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - - /RemoveBucketAlias: - post: - tags: - - Bucket aliases - operationId: "RemoveBucketAlias" - summary: "Remove an alias from a bucket" - description: | - Remove an alias for the target bucket. - This can be a local alias if `accessKeyId` is specified, - or a global alias otherwise. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [bucketId] - properties: - bucketId: - type: string - example: e6a14cd6a27f48684579ec6b381c078ab11697e6bc8513b72b2f5307e25fff9b - globalAlias: - type: string - example: the_bucket - localAlias: - type: string - accessKeyId: - type: string - responses: - '500': - description: "The server can not handle your request. Check your connectivity with the rest of the cluster." - '400': - description: "Bad request, check your request body" - '404': - description: "Bucket not found" - '200': - description: Returns exhaustive information about the bucket - content: - application/json: - schema: - $ref: '#/components/schemas/BucketInfo' - -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - schemas: - NodeNetworkInfo: - type: object - required: [ addr, isUp, lastSeenSecsAgo, hostname ] - properties: - id: - type: string - example: "6a8e08af2aab1083ebab9b22165ea8b5b9d333b60a39ecd504e85cc1f432c36f" - addr: - type: string - example: "10.0.0.11:3901" - isUp: - type: boolean - example: true - lastSeenSecsAgo: - type: integer - nullable: true - example: 9 - hostname: - type: string - example: "node1" - NodeClusterInfo: - type: object - required: [ id, zone, tags ] - properties: - zone: - type: string - example: dc1 - capacity: - type: integer - format: int64 - nullable: true - example: 4 - tags: - type: array - description: | - User defined tags, put whatever makes sense for you, these tags are not interpreted by Garage - example: - - gateway - - fast - items: - type: string - NodeRoleChange: - oneOf: - - $ref: '#/components/schemas/NodeRoleRemove' - - $ref: '#/components/schemas/NodeRoleUpdate' - NodeRoleRemove: - type: object - required: [ id, remove ] - properties: - id: - type: string - example: "6a8e08af2aab1083ebab9b22165ea8b5b9d333b60a39ecd504e85cc1f432c36f" - remove: - type: boolean - example: true - NodeRoleUpdate: - type: object - required: [ id, zone, capacity, tags ] - properties: - id: - type: string - example: "6a8e08af2aab1083ebab9b22165ea8b5b9d333b60a39ecd504e85cc1f432c36f" - zone: - type: string - example: "dc1" - capacity: - type: integer - format: int64 - nullable: true - example: 150000000000 - tags: - type: array - items: - type: string - example: - - gateway - - fast - - ClusterLayout: - type: object - required: [ version, roles, stagedRoleChanges ] - properties: - version: - type: integer - example: 12 - roles: - type: array - example: - - id: "ec79480e0ce52ae26fd00c9da684e4fa56658d9c64cdcecb094e936de0bfe71f" - zone: "madrid" - capacity: 300000000000 - tags: - - fast - - amd64 - - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" - zone: "geneva" - capacity: 700000000000 - tags: - - arm64 - items: - $ref: '#/components/schemas/NodeClusterInfo' - stagedRoleChanges: - type: array - example: - - id: "e2ee7984ee65b260682086ec70026165903c86e601a4a5a501c1900afe28d84b" - zone: "geneva" - capacity: 800000000000 - tags: - - gateway - - id: "4a6ae5a1d0d33bf895f5bb4f0a418b7dc94c47c0dd2eb108d1158f3c8f60b0ff" - remove: true - items: - $ref: '#/components/schemas/NodeRoleChange' - LayoutVersion: - type: object - required: [ version ] - properties: - version: - type: integer - #format: int64 - example: 13 - - KeyInfo: - type: object - properties: - name: - type: string - example: "test-key" - accessKeyId: - type: string - example: "GK31c2f218a2e44f485b94239e" - secretAccessKey: - type: string - nullable: true - example: "b892c0665f0ada8a4755dae98baa3b133590e11dae3bcc1f9d769d67f16c3835" - permissions: - type: object - properties: - createBucket: - type: boolean - example: false - buckets: - type: array - items: - type: object - properties: - id: - type: string - example: "70dc3bed7fe83a75e46b66e7ddef7d56e65f3c02f9f80b6749fb97eccb5e1033" - globalAliases: - type: array - items: - type: string - example: "my-bucket" - localAliases: - type: array - items: - type: string - example: "GK31c2f218a2e44f485b94239e:localname" - permissions: - type: object - properties: - read: - type: boolean - example: true - write: - type: boolean - example: true - owner: - type: boolean - example: false - BucketInfo: - type: object - properties: - id: - type: string - example: afa8f0a22b40b1247ccd0affb869b0af5cff980924a20e4b5e0720a44deb8d39 - globalAliases: - type: array - items: - type: string - example: "my_documents" - websiteAccess: - type: boolean - example: true - websiteConfig: - type: object - nullable: true - properties: - indexDocument: - type: string - example: "index.html" - errorDocument: - type: string - example: "error/400.html" - keys: - type: array - items: - $ref: '#/components/schemas/BucketKeyInfo' - objects: - type: integer - format: int64 - example: 14827 - bytes: - type: integer - format: int64 - example: 13189855625 - unfinishedUploads: - type: integer - example: 0 - quotas: - type: object - properties: - maxSize: - nullable: true - type: integer - format: int64 - example: null - maxObjects: - nullable: true - type: integer - format: int64 - example: null - - - BucketKeyInfo: - type: object - properties: - accessKeyId: - type: string - name: - type: string - permissions: - type: object - properties: - read: - type: boolean - example: true - write: - type: boolean - example: true - owner: - type: boolean - example: true - bucketLocalAliases: - type: array - items: - type: string - example: "my_documents" - - -security: - - bearerAuth: [] - -servers: - - description: A local server - url: http://localhost:3903/v2/ diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 883bded9..b4e2350a 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -38,6 +38,7 @@ url.workspace = true serde.workspace = true serde_json.workspace = true +utoipa.workspace = true opentelemetry.workspace = true opentelemetry-prometheus = { workspace = true, optional = true } diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 4ab28e2d..09c23817 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use paste::paste; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use garage_rpc::*; @@ -155,18 +156,19 @@ pub struct MetricsRequest; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetClusterStatusRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterStatusResponse { pub layout_version: u64, pub nodes: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeResp { pub id: String, pub role: Option, + #[schema(value_type = Option )] pub addr: Option, pub hostname: Option, pub is_up: bool, @@ -178,7 +180,7 @@ pub struct NodeResp { pub metadata_partition: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeRoleResp { pub id: String, @@ -187,7 +189,7 @@ pub struct NodeRoleResp { pub tags: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct FreeSpaceResp { pub available: u64, @@ -199,28 +201,39 @@ pub struct FreeSpaceResp { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetClusterHealthRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterHealthResponse { + /// One of `healthy`, `degraded` or `unavailable`: + /// - healthy: Garage node is connected to all storage nodes + /// - degraded: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions + /// - unavailable: a quorum of write nodes is not available for some partitions pub status: String, + /// the number of nodes this Garage node has had a TCP connection to since the daemon started pub known_nodes: usize, + /// the nubmer of nodes this Garage node currently has an open connection to pub connected_nodes: usize, + /// the number of storage nodes currently registered in the cluster layout pub storage_nodes: usize, + /// the number of storage nodes to which a connection is currently open pub storage_nodes_ok: usize, + /// the total number of partitions of the data (currently always 256) pub partitions: usize, + /// the number of partitions for which a quorum of write nodes is available pub partitions_quorum: usize, + /// the number of partitions for which we are connected to all storage nodes responsible of storing it pub partitions_all_ok: usize, } // ---- ConnectClusterNodes ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ConnectClusterNodesRequest(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ConnectClusterNodesResponse(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ConnectNodeResponse { pub success: bool, @@ -232,7 +245,7 @@ pub struct ConnectNodeResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetClusterLayoutRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterLayoutResponse { pub version: u64, @@ -240,7 +253,7 @@ pub struct GetClusterLayoutResponse { pub staged_role_changes: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeRoleChange { pub id: String, @@ -248,7 +261,7 @@ pub struct NodeRoleChange { pub action: NodeRoleChangeEnum, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(untagged)] pub enum NodeRoleChangeEnum { #[serde(rename_all = "camelCase")] @@ -263,21 +276,21 @@ pub enum NodeRoleChangeEnum { // ---- UpdateClusterLayout ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateClusterLayoutRequest(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); // ---- ApplyClusterLayout ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutRequest { pub version: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutResponse { pub message: Vec, @@ -289,7 +302,7 @@ pub struct ApplyClusterLayoutResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RevertClusterLayoutRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); // ********************************************** @@ -301,10 +314,10 @@ pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListKeysRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ListKeysResponse(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ListKeysResponseItem { pub id: String, @@ -320,7 +333,7 @@ pub struct GetKeyInfoRequest { pub show_secret_key: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetKeyInfoResponse { pub name: String, @@ -331,14 +344,14 @@ pub struct GetKeyInfoResponse { pub buckets: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct KeyPerm { #[serde(default)] pub create_bucket: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct KeyInfoBucketResponse { pub id: String, @@ -347,7 +360,7 @@ pub struct KeyInfoBucketResponse { pub permissions: ApiBucketKeyPerm, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApiBucketKeyPerm { #[serde(default)] @@ -360,18 +373,18 @@ pub struct ApiBucketKeyPerm { // ---- CreateKey ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateKeyRequest { pub name: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateKeyResponse(pub GetKeyInfoResponse); // ---- ImportKey ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ImportKeyRequest { pub access_key_id: String, @@ -379,7 +392,7 @@ pub struct ImportKeyRequest { pub name: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ImportKeyResponse(pub GetKeyInfoResponse); // ---- UpdateKey ---- @@ -390,10 +403,10 @@ pub struct UpdateKeyRequest { pub body: UpdateKeyRequestBody, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateKeyResponse(pub GetKeyInfoResponse); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateKeyRequestBody { pub name: Option, @@ -420,10 +433,10 @@ pub struct DeleteKeyResponse; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListBucketsRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ListBucketsResponse(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ListBucketsResponseItem { pub id: String, @@ -431,7 +444,7 @@ pub struct ListBucketsResponseItem { pub local_aliases: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct BucketLocalAlias { pub access_key_id: String, @@ -447,32 +460,44 @@ pub struct GetBucketInfoRequest { pub search: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoResponse { + /// Identifier of the bucket pub id: String, + /// List of global aliases for this bucket pub global_aliases: Vec, + /// Whether website acces is enabled for this bucket pub website_access: bool, #[serde(default)] + /// Website configuration for this bucket pub website_config: Option, + /// List of access keys that have permissions granted on this bucket pub keys: Vec, + /// Number of objects in this bucket pub objects: i64, + /// Total number of bytes used by objects in this bucket pub bytes: i64, + /// Number of unfinished uploads in this bucket pub unfinished_uploads: i64, + /// Number of unfinished multipart uploads in this bucket pub unfinished_multipart_uploads: i64, + /// Number of parts in unfinished multipart uploads in this bucket pub unfinished_multipart_upload_parts: i64, + /// Total number of bytes used by unfinished multipart uploads in this bucket pub unfinished_multipart_upload_bytes: i64, + /// Quotas that apply to this bucket pub quotas: ApiBucketQuotas, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoWebsiteResponse { pub index_document: String, pub error_document: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoKey { pub access_key_id: String, @@ -481,7 +506,7 @@ pub struct GetBucketInfoKey { pub bucket_local_aliases: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApiBucketQuotas { pub max_size: Option, @@ -490,17 +515,17 @@ pub struct ApiBucketQuotas { // ---- CreateBucket ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateBucketRequest { pub global_alias: Option, pub local_alias: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateBucketResponse(pub GetBucketInfoResponse); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateBucketLocalAlias { pub access_key_id: String, @@ -517,17 +542,17 @@ pub struct UpdateBucketRequest { pub body: UpdateBucketRequestBody, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateBucketResponse(pub GetBucketInfoResponse); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateBucketRequestBody { pub website_access: Option, pub quotas: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateBucketWebsiteAccess { pub enabled: bool, @@ -547,13 +572,13 @@ pub struct DeleteBucketResponse; // ---- CleanupIncompleteUploads ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CleanupIncompleteUploadsRequest { pub bucket_id: String, pub older_than_secs: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CleanupIncompleteUploadsResponse { pub uploads_deleted: u64, } @@ -564,13 +589,13 @@ pub struct CleanupIncompleteUploadsResponse { // ---- AllowBucketKey ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AllowBucketKeyRequest(pub BucketKeyPermChangeRequest); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AllowBucketKeyResponse(pub GetBucketInfoResponse); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct BucketKeyPermChangeRequest { pub bucket_id: String, @@ -580,10 +605,10 @@ pub struct BucketKeyPermChangeRequest { // ---- DenyBucketKey ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DenyBucketKeyRequest(pub BucketKeyPermChangeRequest); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // ********************************************** @@ -592,7 +617,7 @@ pub struct DenyBucketKeyResponse(pub GetBucketInfoResponse); // ---- AddBucketAlias ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct AddBucketAliasRequest { pub bucket_id: String, @@ -600,10 +625,10 @@ pub struct AddBucketAliasRequest { pub alias: BucketAliasEnum, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AddBucketAliasResponse(pub GetBucketInfoResponse); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(untagged)] pub enum BucketAliasEnum { #[serde(rename_all = "camelCase")] @@ -617,7 +642,7 @@ pub enum BucketAliasEnum { // ---- RemoveBucketAlias ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RemoveBucketAliasRequest { pub bucket_id: String, @@ -625,7 +650,7 @@ pub struct RemoveBucketAliasRequest { pub alias: BucketAliasEnum, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); // ********************************************** diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index dd9b7ffd..3993b906 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -6,6 +6,7 @@ mod error; mod macros; pub mod api; +pub mod openapi; mod router_v0; mod router_v1; mod router_v2; diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs new file mode 100644 index 00000000..63f3d36c --- /dev/null +++ b/src/api/admin/openapi.rs @@ -0,0 +1,483 @@ +#![allow(dead_code)] +#![allow(non_snake_case)] + +use utoipa::{OpenApi, Modify}; + +use crate::api::*; + +// ********************************************** +// Cluster operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/GetClusterStatus", + tag = "Nodes", + description = " +Returns the cluster's current status, including: + +- ID of the node being queried and its version of the Garage daemon +- Live nodes +- Currently configured cluster layout +- Staged changes to the cluster layout + +*Capacity is given in bytes* + ", + responses( + (status = 200, description = "Cluster status report", body = GetClusterStatusResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetClusterStatus() -> () {} + +#[utoipa::path(get, + path = "/v2/GetClusterHealth", + tag = "Nodes", + description = "Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total).", + responses( + (status = 200, description = "Cluster health report", body = GetClusterHealthResponse), + ), +)] +fn GetClusterHealth() -> () {} + +#[utoipa::path(post, + path = "/v2/ConnectClusterNodes", + tag = "Nodes", + description = "Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start.", + request_body=ConnectClusterNodesRequest, + responses( + (status = 200, description = "The request has been handled correctly but it does not mean that all connection requests succeeded; some might have fail, you need to check the body!", body = ConnectClusterNodesResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ConnectClusterNodes() -> () {} + +#[utoipa::path(get, + path = "/v2/GetClusterLayout", + tag = "Layout", + description = " +Returns the cluster's current layout, including: + +- Currently configured cluster layout +- Staged changes to the cluster layout + +*Capacity is given in bytes* +*The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.* + ", + responses( + (status = 200, description = "Current cluster layout", body = GetClusterLayoutResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetClusterLayout() -> () {} + +#[utoipa::path(post, + path = "/v2/UpdateClusterLayout", + tag = "Layout", + description = " +Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /GetClusterHealth`. Once the set of staged changes is satisfactory, the user may call `POST /ApplyClusterLayout` to apply the changed changes, or `POST /RevertClusterLayout` to clear all of the staged changes in the layout. + +Setting the capacity to `null` will configure the node as a gateway. +Otherwise, capacity must be now set in bytes (before Garage 0.9 it was arbitrary weights). +For example to declare 100GB, you must set `capacity: 100000000000`. + +Garage uses internally the International System of Units (SI), it assumes that 1kB = 1000 bytes, and displays storage as kB, MB, GB (and not KiB, MiB, GiB that assume 1KiB = 1024 bytes). + ", + request_body( + content=UpdateClusterLayoutRequest, + description=" +To add a new node to the layout or to change the configuration of an existing node, simply set the values you want (`zone`, `capacity`, and `tags`). +To remove a node, simply pass the `remove: true` field. +This logic is represented in OpenAPI with a 'One Of' object. + +Contrary to the CLI that may update only a subset of the fields capacity, zone and tags, when calling this API all of these values must be specified. + " + ), + responses( + (status = 200, description = "Proposed changes have been added to the list of pending changes", body = UpdateClusterLayoutResponse), + (status = 500, description = "Internal server error") + ), +)] +fn UpdateClusterLayout() -> () {} + +#[utoipa::path(post, + path = "/v2/ApplyClusterLayout", + tag = "Layout", + description = " +Applies to the cluster the layout changes currently registered as staged layout changes. + +*Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.* + ", + request_body=ApplyClusterLayoutRequest, + responses( + (status = 200, description = "The updated cluster layout has been applied in the cluster", body = ApplyClusterLayoutResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ApplyClusterLayout() -> () {} + +#[utoipa::path(post, + path = "/v2/RevertClusterLayout", + tag = "Layout", + description = "Clear staged layout", + responses( + (status = 200, description = "All pending changes to the cluster layout have been erased", body = RevertClusterLayoutResponse), + (status = 500, description = "Internal server error") + ), +)] +fn RevertClusterLayout() -> () {} + +// ********************************************** +// Access key operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/ListKeys", + tag = "Key", + description = "Returns all API access keys in the cluster.", + responses( + (status = 200, description = "Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string)", body = ListKeysResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ListKeys() -> () {} + +#[utoipa::path(get, + path = "/v2/GetKeyInfo", + tag = "Key", + description = " +Return information about a specific key like its identifiers, its permissions and buckets on which it has permissions. +You can search by specifying the exact key identifier (`id`) or by specifying a pattern (`search`). + +For confidentiality reasons, the secret key is not returned by default: you must pass the `showSecretKey` query parameter to get it. + ", + params( + ("id", description = "Access key ID"), + ("search", description = "Partial key ID or name to search for"), + ("showSecretKey", description = "Whether to return the secret access key"), + ), + responses( + (status = 200, description = "Information about the access key", body = GetKeyInfoResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetKeyInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/CreateKey", + tag = "Key", + description = "Creates a new API access key.", + request_body = CreateKeyRequest, + responses( + (status = 200, description = "Access key has been created", body = CreateKeyResponse), + (status = 500, description = "Internal server error") + ), +)] +fn CreateKey() -> () {} + +#[utoipa::path(post, + path = "/v2/ImportKey", + tag = "Key", + description = " +Imports an existing API key. This feature must only be used for migrations and backup restore. + +**Do not use it to generate custom key identifiers or you will break your Garage cluster.** + ", + request_body = ImportKeyRequest, + responses( + (status = 200, description = "Access key has been imported", body = ImportKeyResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ImportKey() -> () {} + +#[utoipa::path(post, + path = "/v2/UpdateKey", + tag = "Key", + description = " +Updates information about the specified API access key. + +*Note: the secret key is not returned in the response, `null` is sent instead.* + ", + request_body = UpdateKeyRequestBody, + params( + ("id", description = "Access key ID"), + ), + responses( + (status = 200, description = "Access key has been updated", body = UpdateKeyResponse), + (status = 500, description = "Internal server error") + ), +)] +fn UpdateKey() -> () {} + +#[utoipa::path(post, + path = "/v2/DeleteKey", + tag = "Key", + description = "Delete a key from the cluster. Its access will be removed from all the buckets. Buckets are not automatically deleted and can be dangling. You should manually delete them before. ", + params( + ("id", description = "Access key ID"), + ), + responses( + (status = 200, description = "Access key has been deleted"), + (status = 500, description = "Internal server error") + ), +)] +fn DeleteKey() -> () {} + +// ********************************************** +// Bucket operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/ListBuckets", + tag = "Bucket", + description = "List all the buckets on the cluster with their UUID and their global and local aliases.", + responses( + (status = 200, description = "Returns the UUID of all the buckets and all their aliases", body = ListBucketsResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ListBuckets() -> () {} + +#[utoipa::path(get, + path = "/v2/GetBucketInfo", + tag = "Bucket", + description = " +Given a bucket identifier (`id`) or a global alias (`alias`), get its information. +It includes its aliases, its web configuration, keys that have some permissions +on it, some statistics (number of objects, size), number of dangling multipart uploads, +and its quotas (if any). + ", + params( + ("id", description = "Exact bucket ID to look up"), + ("globalAlias", description = "Global alias of bucket to look up"), + ("search", description = "Partial ID or alias to search for"), + ), + responses( + (status = 200, description = "Returns exhaustive information about the bucket", body = GetBucketInfoResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetBucketInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/CreateBucket", + tag = "Bucket", + description = " +Creates a new bucket, either with a global alias, a local one, or no alias at all. +Technically, you can also specify both `globalAlias` and `localAlias` and that would create two aliases. + ", + request_body = CreateBucketRequest, + responses( + (status = 200, description = "Returns exhaustive information about the bucket", body = CreateBucketResponse), + (status = 500, description = "Internal server error") + ), +)] +fn CreateBucket() -> () {} + +#[utoipa::path(post, + path = "/v2/UpdateBucket", + tag = "Bucket", + description = " +All fields (`websiteAccess` and `quotas`) are optional. +If they are present, the corresponding modifications are applied to the bucket, otherwise nothing is changed. + +In `websiteAccess`: if `enabled` is `true`, `indexDocument` must be specified. +The field `errorDocument` is optional, if no error document is set a generic +error message is displayed when errors happen. Conversely, if `enabled` is +`false`, neither `indexDocument` nor `errorDocument` must be specified. + +In `quotas`: new values of `maxSize` and `maxObjects` must both be specified, or set to `null` +to remove the quotas. An absent value will be considered the same as a `null`. It is not possible +to change only one of the two quotas. + ", + params( + ("id", description = "ID of the bucket to update"), + ), + request_body = UpdateBucketRequestBody, + responses( + (status = 200, description = "Bucket has been updated", body = UpdateBucketResponse), + (status = 404, description = "Bucket not found"), + (status = 500, description = "Internal server error") + ), +)] +fn UpdateBucket() -> () {} + +#[utoipa::path(post, + path = "/v2/DeleteBucket", + tag = "Bucket", + description = " +Deletes a storage bucket. A bucket cannot be deleted if it is not empty. + +**Warning:** this will delete all aliases associated with the bucket! + ", + params( + ("id", description = "ID of the bucket to delete"), + ), + responses( + (status = 200, description = "Bucket has been deleted"), + (status = 400, description = "Bucket is not empty"), + (status = 404, description = "Bucket not found"), + (status = 500, description = "Internal server error") + ), +)] +fn DeleteBucket() -> () {} + +#[utoipa::path(post, + path = "/v2/CleanupIncompleteUploads", + tag = "Bucket", + description = "Removes all incomplete multipart uploads that are older than the specified number of seconds.", + request_body = CleanupIncompleteUploadsRequest, + responses( + (status = 200, description = "The bucket was cleaned up successfully", body = CleanupIncompleteUploadsResponse), + (status = 500, description = "Internal server error") + ), +)] +fn CleanupIncompleteUploads() -> () {} + +// ********************************************** +// Operations on permissions for keys on buckets +// ********************************************** + +#[utoipa::path(post, + path = "/v2/AllowBucketKey", + tag = "Permission", + description = " +⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. + +Allows a key to do read/write/owner operations on a bucket. + +Flags in permissions which have the value true will be activated. Other flags will remain unchanged (ie. they will keep their internal value). + +For example, if you set read to true, the key will be allowed to read the bucket. +If you set it to false, the key will keeps its previous read permission. +If you want to disallow read for the key, check the DenyBucketKey operation. + ", + request_body = AllowBucketKeyRequest, + responses( + (status = 200, description = "Returns exhaustive information about the bucket", body = AllowBucketKeyResponse), + (status = 500, description = "Internal server error") + ), +)] +fn AllowBucketKey() -> () {} + +#[utoipa::path(post, + path = "/v2/DenyBucketKey", + tag = "Permission", + description = " +⚠️ **DISCLAIMER**: Garage's developers are aware that this endpoint has an unconventional semantic. Be extra careful when implementing it, its behavior is not obvious. + +Denies a key from doing read/write/owner operations on a bucket. + +Flags in permissions which have the value true will be deactivated. Other flags will remain unchanged. + +For example, if you set read to true, the key will be denied from reading. +If you set read to false, the key will keep its previous permissions. +If you want the key to have the reading permission, check the AllowBucketKey operation. + ", + request_body = DenyBucketKeyRequest, + responses( + (status = 200, description = "Returns exhaustive information about the bucket", body = DenyBucketKeyResponse), + (status = 500, description = "Internal server error") + ), +)] +fn DenyBucketKey() -> () {} + +// ********************************************** +// Operations on bucket aliases +// ********************************************** + +#[utoipa::path(post, + path = "/v2/AddBucketAlias", + tag = "Alias", + description = "Add an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + request_body = AddBucketAliasRequest, + responses( + (status = 200, description = "Returns exhaustive information about the bucket", body = AddBucketAliasResponse), + (status = 500, description = "Internal server error") + ), +)] +fn AddBucketAlias() -> () {} + +#[utoipa::path(post, + path = "/v2/RemoveBucketAlias", + tag = "Alias", + description = "Remove an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + request_body = RemoveBucketAliasRequest, + responses( + (status = 200, description = "Returns exhaustive information about the bucket", body = RemoveBucketAliasResponse), + (status = 500, description = "Internal server error") + ), +)] +fn RemoveBucketAlias() -> () {} + +// ********************************************** +// ********************************************** +// ********************************************** + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::*; + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "bearerAuth", + SecurityScheme::Http(Http::builder() + .scheme(HttpAuthScheme::Bearer) + .build()), + ) + } +} + + +#[derive(OpenApi)] +#[openapi( + info( + version = "v2.0.0", + title = "Garage administration API", + description = "Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks. + +*Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!*", + contact( + name = "The Garage team", + email = "garagehq@deuxfleurs.fr", + url = "https://garagehq.deuxfleurs.fr/", + ), + ), + modifiers(&SecurityAddon), + security(("bearerAuth" = [])), + paths( + // Cluster operations + GetClusterHealth, + GetClusterStatus, + ConnectClusterNodes, + GetClusterLayout, + UpdateClusterLayout, + ApplyClusterLayout, + RevertClusterLayout, + // Key operations + ListKeys, + GetKeyInfo, + CreateKey, + ImportKey, + UpdateKey, + DeleteKey, + // Bucket operations + ListBuckets, + GetBucketInfo, + CreateBucket, + UpdateBucket, + DeleteBucket, + CleanupIncompleteUploads, + // Operations on permissions + AllowBucketKey, + DenyBucketKey, + // Operations on aliases + AddBucketAlias, + RemoveBucketAlias, + ), + servers( + (url = "http://localhost:3903/", description = "A local server") + ), +)] +pub struct ApiDoc; diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index 6bbf83ac..ba747fdf 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -48,6 +48,7 @@ sha1.workspace = true sodiumoxide.workspace = true structopt.workspace = true git-version.workspace = true +utoipa.workspace = true futures.workspace = true tokio.workspace = true diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index c6471515..58d066b3 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -58,6 +58,10 @@ pub enum Command { /// Convert metadata db between database engine formats #[structopt(name = "convert-db", version = garage_version())] ConvertDb(convert_db::ConvertDbOpt), + + /// Output openapi JSON schema for admin api + #[structopt(name = "admin-api-schema", version = garage_version(), setting(structopt::clap::AppSettings::Hidden))] + AdminApiSchema, } #[derive(StructOpt, Debug)] diff --git a/src/garage/main.rs b/src/garage/main.rs index 2a88d760..9e3e3fb6 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -24,6 +24,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use structopt::StructOpt; +use utoipa::OpenApi; use garage_net::util::parse_and_resolve_peer_addr; use garage_net::NetworkKey; @@ -151,6 +152,10 @@ async fn main() { Command::Node(NodeOperation::NodeId(node_id_opt)) => { cli::init::node_id_command(opt.config_file, node_id_opt.quiet) } + Command::AdminApiSchema => { + println!("{}", garage_api_admin::openapi::ApiDoc::openapi().to_pretty_json().unwrap()); + Ok(()) + } _ => cli_command(opt).await, }; From 411f1d495cc702d85302d493f8268ff019bd0f42 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 14:01:39 +0100 Subject: [PATCH 056/192] admin api: add all missing endpoints to openapi spec --- doc/api/garage-admin-v2.json | 1607 +++++++++++++++++++++++++++++++++- src/api/admin/api.rs | 131 +-- src/api/admin/openapi.rs | 294 ++++++- src/garage/main.rs | 13 +- 4 files changed, 1932 insertions(+), 113 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 5aa6bcb6..7b705832 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -24,7 +24,7 @@ "/v2/AddBucketAlias": { "post": { "tags": [ - "Alias" + "Bucket alias" ], "description": "Add an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", "operationId": "AddBucketAlias", @@ -92,7 +92,7 @@ "/v2/ApplyClusterLayout": { "post": { "tags": [ - "Layout" + "Cluster layout" ], "description": "\nApplies to the cluster the layout changes currently registered as staged layout changes.\n\n*Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.*\n ", "operationId": "ApplyClusterLayout", @@ -160,7 +160,7 @@ "/v2/ConnectClusterNodes": { "post": { "tags": [ - "Nodes" + "Cluster" ], "description": "Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start.", "operationId": "ConnectClusterNodes", @@ -228,7 +228,7 @@ "/v2/CreateKey": { "post": { "tags": [ - "Key" + "Access key" ], "description": "Creates a new API access key.", "operationId": "CreateKey", @@ -259,6 +259,38 @@ } } }, + "/v2/CreateMetadataSnapshot": { + "post": { + "tags": [ + "Node" + ], + "description": "\nInstruct one or several nodes to take a snapshot of their metadata databases.\n ", + "operationId": "CreateMetadataSnapshot", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalCreateMetadataSnapshotResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/DeleteBucket": { "post": { "tags": [ @@ -293,7 +325,7 @@ "/v2/DeleteKey": { "post": { "tags": [ - "Key" + "Access key" ], "description": "Delete a key from the cluster. Its access will be removed from all the buckets. Buckets are not automatically deleted and can be dangling. You should manually delete them before. ", "operationId": "DeleteKey", @@ -349,6 +381,48 @@ } } }, + "/v2/GetBlockInfo": { + "post": { + "tags": [ + "Block" + ], + "description": "\nGet detailed information about a data block stored on a Garage node, including all object versions and in-progress multipart uploads that contain a reference to this block.\n ", + "operationId": "GetBlockInfo", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalGetBlockInfoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Detailed block information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalGetBlockInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/GetBucketInfo": { "get": { "tags": [ @@ -396,7 +470,7 @@ "/v2/GetClusterHealth": { "get": { "tags": [ - "Nodes" + "Cluster" ], "description": "Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total).", "operationId": "GetClusterHealth", @@ -417,7 +491,7 @@ "/v2/GetClusterLayout": { "get": { "tags": [ - "Layout" + "Cluster layout" ], "description": "\nReturns the cluster's current layout, including:\n\n- Currently configured cluster layout\n- Staged changes to the cluster layout\n\n*Capacity is given in bytes*\n*The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.*\n ", "operationId": "GetClusterLayout", @@ -438,10 +512,34 @@ } } }, + "/v2/GetClusterStatistics": { + "get": { + "tags": [ + "Node" + ], + "description": "\nFetch global cluster statistics.\n ", + "operationId": "GetClusterStatistics", + "responses": { + "200": { + "description": "Global cluster statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetClusterStatisticsResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/GetClusterStatus": { "get": { "tags": [ - "Nodes" + "Cluster" ], "description": "\nReturns the cluster's current status, including:\n\n- ID of the node being queried and its version of the Garage daemon\n- Live nodes\n- Currently configured cluster layout\n- Staged changes to the cluster layout\n\n*Capacity is given in bytes*\n ", "operationId": "GetClusterStatus", @@ -465,7 +563,7 @@ "/v2/GetKeyInfo": { "get": { "tags": [ - "Key" + "Access key" ], "description": "\nReturn information about a specific key like its identifiers, its permissions and buckets on which it has permissions.\nYou can search by specifying the exact key identifier (`id`) or by specifying a pattern (`search`).\n\nFor confidentiality reasons, the secret key is not returned by default: you must pass the `showSecretKey` query parameter to get it.\n ", "operationId": "GetKeyInfo", @@ -506,10 +604,158 @@ } } }, + "/v2/GetNodeInfo": { + "get": { + "tags": [ + "Node" + ], + "description": "\nReturn information about the Garage daemon running on one or several nodes.\n ", + "operationId": "GetNodeInfo", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalGetNodeInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetNodeStatistics": { + "get": { + "tags": [ + "Node" + ], + "description": "\nFetch statistics for one or several Garage nodes.\n ", + "operationId": "GetNodeStatistics", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalGetNodeStatisticsResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetWorkerInfo": { + "post": { + "tags": [ + "Worker" + ], + "description": "\nGet information about the specified background worker on one or several cluster nodes.\n ", + "operationId": "GetWorkerInfo", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalGetWorkerInfoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalGetWorkerInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/GetWorkerVariable": { + "post": { + "tags": [ + "Worker" + ], + "description": "\nFetch values of one or several worker variables, from one or several cluster nodes.\n ", + "operationId": "GetWorkerVariable", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalGetWorkerVariableRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalGetWorkerVariableResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/ImportKey": { "post": { "tags": [ - "Key" + "Access key" ], "description": "\nImports an existing API key. This feature must only be used for migrations and backup restore.\n\n**Do not use it to generate custom key identifiers or you will break your Garage cluster.**\n ", "operationId": "ImportKey", @@ -540,6 +786,80 @@ } } }, + "/v2/LaunchRepairOperation": { + "post": { + "tags": [ + "Node" + ], + "description": "\nLaunch a repair operation on one or several cluster noes.\n ", + "operationId": "LaunchRepairOperation", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalLaunchRepairOperationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalLaunchRepairOperationResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/ListBlockErrors": { + "get": { + "tags": [ + "Block" + ], + "description": "\nList data blocks that are currently in an errored state on one or several Garage nodes.\n ", + "operationId": "ListBlockErrors", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalListBlockErrorsResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/ListBuckets": { "get": { "tags": [ @@ -567,7 +887,7 @@ "/v2/ListKeys": { "get": { "tags": [ - "Key" + "Access key" ], "description": "Returns all API access keys in the cluster.", "operationId": "ListKeys", @@ -588,10 +908,94 @@ } } }, + "/v2/ListWorkers": { + "post": { + "tags": [ + "Worker" + ], + "description": "\nList background workers currently running on one or several cluster nodes.\n ", + "operationId": "ListWorkers", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalListWorkersRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalListWorkersResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v2/PurgeBlocks": { + "post": { + "tags": [ + "Block" + ], + "description": "\nPurge references to one or several missing data blocks.\n\nThis will remove all objects and in-progress multipart uploads that contain the specified data block(s). The objects will be permanently deleted from the buckets in which they appear. Use with caution.\n ", + "operationId": "PurgeBlocks", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalPurgeBlocksRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalPurgeBlocksResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/RemoveBucketAlias": { "post": { "tags": [ - "Alias" + "Bucket alias" ], "description": "Remove an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", "operationId": "RemoveBucketAlias", @@ -622,10 +1026,52 @@ } } }, + "/v2/RetryBlockResync": { + "post": { + "tags": [ + "Block" + ], + "description": "\nInstruct Garage node(s) to retry the resynchronization of one or several missing data block(s).\n ", + "operationId": "RetryBlockResync", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalRetryBlockResyncRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalRetryBlockResyncResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/RevertClusterLayout": { "post": { "tags": [ - "Layout" + "Cluster layout" ], "description": "Clear staged layout", "operationId": "RevertClusterLayout", @@ -646,6 +1092,48 @@ } } }, + "/v2/SetWorkerVariable": { + "post": { + "tags": [ + "Worker" + ], + "description": "\nSet the value for a worker variable, on one or several cluster nodes.\n ", + "operationId": "SetWorkerVariable", + "parameters": [ + { + "name": "node", + "in": "path", + "description": "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalSetWorkerVariableRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Responses from individual cluster nodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiResponse_LocalSetWorkerVariableResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/UpdateBucket": { "post": { "tags": [ @@ -694,7 +1182,7 @@ "/v2/UpdateClusterLayout": { "post": { "tags": [ - "Layout" + "Cluster layout" ], "description": "\nSend modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /GetClusterHealth`. Once the set of staged changes is satisfactory, the user may call `POST /ApplyClusterLayout` to apply the changed changes, or `POST /RevertClusterLayout` to clear all of the staged changes in the layout.\n\nSetting the capacity to `null` will configure the node as a gateway.\nOtherwise, capacity must be now set in bytes (before Garage 0.9 it was arbitrary weights).\nFor example to declare 100GB, you must set `capacity: 100000000000`.\n\nGarage uses internally the International System of Units (SI), it assumes that 1kB = 1000 bytes, and displays storage as kB, MB, GB (and not KiB, MiB, GiB that assume 1KiB = 1024 bytes).\n ", "operationId": "UpdateClusterLayout", @@ -729,7 +1217,7 @@ "/v2/UpdateKey": { "post": { "tags": [ - "Key" + "Access key" ], "description": "\nUpdates information about the specified API access key.\n\n*Note: the secret key is not returned in the response, `null` is sent instead.*\n ", "operationId": "UpdateKey", @@ -864,6 +1352,136 @@ } } }, + "BlockError": { + "type": "object", + "required": [ + "blockHash", + "refcount", + "errorCount", + "lastTrySecsAgo", + "nextTryInSecs" + ], + "properties": { + "blockHash": { + "type": "string" + }, + "errorCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "lastTrySecsAgo": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "nextTryInSecs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "refcount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "BlockVersion": { + "type": "object", + "required": [ + "versionId", + "deleted", + "garbageCollected" + ], + "properties": { + "backlink": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BlockVersionBacklink" + } + ] + }, + "deleted": { + "type": "boolean" + }, + "garbageCollected": { + "type": "boolean" + }, + "versionId": { + "type": "string" + } + } + }, + "BlockVersionBacklink": { + "oneOf": [ + { + "type": "object", + "required": [ + "object" + ], + "properties": { + "object": { + "type": "object", + "required": [ + "bucketId", + "key" + ], + "properties": { + "bucketId": { + "type": "string" + }, + "key": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": [ + "upload" + ], + "properties": { + "upload": { + "type": "object", + "required": [ + "uploadId", + "uploadDeleted", + "uploadGarbageCollected" + ], + "properties": { + "bucketId": { + "type": [ + "string", + "null" + ] + }, + "key": { + "type": [ + "string", + "null" + ] + }, + "uploadDeleted": { + "type": "boolean" + }, + "uploadGarbageCollected": { + "type": "boolean" + }, + "uploadId": { + "type": "string" + } + } + } + } + } + ] + }, "BucketAliasEnum": { "oneOf": [ { @@ -931,14 +1549,14 @@ "CleanupIncompleteUploadsRequest": { "type": "object", "required": [ - "bucket_id", - "older_than_secs" + "bucketId", + "olderThanSecs" ], "properties": { - "bucket_id": { + "bucketId": { "type": "string" }, - "older_than_secs": { + "olderThanSecs": { "type": "integer", "format": "int64", "minimum": 0 @@ -948,10 +1566,10 @@ "CleanupIncompleteUploadsResponse": { "type": "object", "required": [ - "uploads_deleted" + "uploadsDeleted" ], "properties": { - "uploads_deleted": { + "uploadsDeleted": { "type": "integer", "format": "int64", "minimum": 0 @@ -1277,6 +1895,17 @@ } } }, + "GetClusterStatisticsResponse": { + "type": "object", + "required": [ + "freeform" + ], + "properties": { + "freeform": { + "type": "string" + } + } + }, "GetClusterStatusResponse": { "type": "object", "required": [ @@ -1442,6 +2071,737 @@ } } }, + "LocalCreateMetadataSnapshotResponse": { + "default": null + }, + "LocalGetBlockInfoRequest": { + "type": "object", + "required": [ + "blockHash" + ], + "properties": { + "blockHash": { + "type": "string" + } + } + }, + "LocalGetBlockInfoResponse": { + "type": "object", + "required": [ + "blockHash", + "refcount", + "versions" + ], + "properties": { + "blockHash": { + "type": "string" + }, + "refcount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockVersion" + } + } + } + }, + "LocalGetNodeInfoResponse": { + "type": "object", + "required": [ + "nodeId", + "garageVersion", + "rustVersion", + "dbEngine" + ], + "properties": { + "dbEngine": { + "type": "string" + }, + "garageFeatures": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "garageVersion": { + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "rustVersion": { + "type": "string" + } + } + }, + "LocalGetNodeStatisticsResponse": { + "type": "object", + "required": [ + "freeform" + ], + "properties": { + "freeform": { + "type": "string" + } + } + }, + "LocalGetWorkerInfoRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "LocalGetWorkerInfoResponse": { + "$ref": "#/components/schemas/WorkerInfoResp" + }, + "LocalGetWorkerVariableRequest": { + "type": "object", + "properties": { + "variable": { + "type": [ + "string", + "null" + ] + } + } + }, + "LocalGetWorkerVariableResponse": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "LocalLaunchRepairOperationRequest": { + "type": "object", + "required": [ + "repairType" + ], + "properties": { + "repairType": { + "$ref": "#/components/schemas/RepairType" + } + } + }, + "LocalLaunchRepairOperationResponse": { + "default": null + }, + "LocalListBlockErrorsResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockError" + } + }, + "LocalListWorkersRequest": { + "type": "object", + "properties": { + "busyOnly": { + "type": "boolean" + }, + "errorOnly": { + "type": "boolean" + } + } + }, + "LocalListWorkersResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkerInfoResp" + } + }, + "LocalPurgeBlocksRequest": { + "type": "array", + "items": { + "type": "string" + } + }, + "LocalPurgeBlocksResponse": { + "type": "object", + "required": [ + "blocksPurged", + "objectsDeleted", + "uploadsDeleted", + "versionsDeleted" + ], + "properties": { + "blocksPurged": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "objectsDeleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "uploadsDeleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "versionsDeleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "LocalRetryBlockResyncRequest": { + "oneOf": [ + { + "type": "object", + "required": [ + "all" + ], + "properties": { + "all": { + "type": "boolean" + } + } + }, + { + "type": "object", + "required": [ + "blockHashes" + ], + "properties": { + "blockHashes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "LocalRetryBlockResyncResponse": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "LocalSetWorkerVariableRequest": { + "type": "object", + "required": [ + "variable", + "value" + ], + "properties": { + "value": { + "type": "string" + }, + "variable": { + "type": "string" + } + } + }, + "LocalSetWorkerVariableResponse": { + "type": "object", + "required": [ + "variable", + "value" + ], + "properties": { + "value": { + "type": "string" + }, + "variable": { + "type": "string" + } + } + }, + "MultiResponse_LocalCreateMetadataSnapshotResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "default": null + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalGetBlockInfoResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "required": [ + "blockHash", + "refcount", + "versions" + ], + "properties": { + "blockHash": { + "type": "string" + }, + "refcount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockVersion" + } + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalGetNodeInfoResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "required": [ + "nodeId", + "garageVersion", + "rustVersion", + "dbEngine" + ], + "properties": { + "dbEngine": { + "type": "string" + }, + "garageFeatures": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "garageVersion": { + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "rustVersion": { + "type": "string" + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalGetNodeStatisticsResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "required": [ + "freeform" + ], + "properties": { + "freeform": { + "type": "string" + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalGetWorkerInfoResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "$ref": "#/components/schemas/WorkerInfoResp" + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalGetWorkerVariableResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalLaunchRepairOperationResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "default": null + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalListBlockErrorsResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockError" + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalListWorkersResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkerInfoResp" + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalPurgeBlocksResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "required": [ + "blocksPurged", + "objectsDeleted", + "uploadsDeleted", + "versionsDeleted" + ], + "properties": { + "blocksPurged": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "objectsDeleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "uploadsDeleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "versionsDeleted": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalRetryBlockResyncResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "MultiResponse_LocalSetWorkerVariableResponse": { + "type": "object", + "required": [ + "success", + "error" + ], + "properties": { + "error": { + "type": "object", + "description": "Map of node id to error message, for nodes that were unable to complete the API\ncall", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "success": { + "type": "object", + "description": "Map of node id to response returned by this node, for nodes that were able to\nsuccessfully complete the API call", + "additionalProperties": { + "type": "object", + "required": [ + "variable", + "value" + ], + "properties": { + "value": { + "type": "string" + }, + "variable": { + "type": "string" + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, "NodeResp": { "type": "object", "required": [ @@ -1621,9 +2981,75 @@ "RemoveBucketAliasResponse": { "$ref": "#/components/schemas/GetBucketInfoResponse" }, + "RepairType": { + "oneOf": [ + { + "type": "string", + "enum": [ + "tables" + ] + }, + { + "type": "string", + "enum": [ + "blocks" + ] + }, + { + "type": "string", + "enum": [ + "versions" + ] + }, + { + "type": "string", + "enum": [ + "multipartUploads" + ] + }, + { + "type": "string", + "enum": [ + "blockRefs" + ] + }, + { + "type": "string", + "enum": [ + "blockRc" + ] + }, + { + "type": "string", + "enum": [ + "rebalance" + ] + }, + { + "type": "object", + "required": [ + "scrub" + ], + "properties": { + "scrub": { + "$ref": "#/components/schemas/ScrubCommand" + } + } + } + ] + }, "RevertClusterLayoutResponse": { "$ref": "#/components/schemas/GetClusterLayoutResponse" }, + "ScrubCommand": { + "type": "string", + "enum": [ + "start", + "pause", + "resume", + "cancel" + ] + }, "UpdateBucketRequestBody": { "type": "object", "properties": { @@ -1717,6 +3143,145 @@ }, "UpdateKeyResponse": { "$ref": "#/components/schemas/GetKeyInfoResponse" + }, + "WorkerInfoResp": { + "type": "object", + "required": [ + "id", + "name", + "state", + "errors", + "consecutiveErrors", + "freeform" + ], + "properties": { + "consecutiveErrors": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "errors": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "freeform": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "lastError": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WorkerLastError" + } + ] + }, + "name": { + "type": "string" + }, + "persistentErrors": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "progress": { + "type": [ + "string", + "null" + ] + }, + "queueLength": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/WorkerStateResp" + }, + "tranquility": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + }, + "WorkerLastError": { + "type": "object", + "required": [ + "message", + "secsAgo" + ], + "properties": { + "message": { + "type": "string" + }, + "secsAgo": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "WorkerStateResp": { + "oneOf": [ + { + "type": "string", + "enum": [ + "busy" + ] + }, + { + "type": "object", + "required": [ + "throttled" + ], + "properties": { + "throttled": { + "type": "object", + "required": [ + "durationSecs" + ], + "properties": { + "durationSecs": { + "type": "number", + "format": "float" + } + } + } + } + }, + { + "type": "string", + "enum": [ + "idle" + ] + }, + { + "type": "string", + "enum": [ + "done" + ] + } + ] } }, "securitySchemes": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 09c23817..9eec880a 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -120,9 +120,13 @@ pub struct MultiRequest { pub body: RB, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct MultiResponse { + /// Map of node id to response returned by this node, for nodes that were able to + /// successfully complete the API call pub success: HashMap, + /// Map of node id to error message, for nodes that were unable to complete the API + /// call pub error: HashMap, } @@ -168,7 +172,7 @@ pub struct GetClusterStatusResponse { pub struct NodeResp { pub id: String, pub role: Option, - #[schema(value_type = Option )] + #[schema(value_type = Option )] pub addr: Option, pub hostname: Option, pub is_up: bool, @@ -204,24 +208,24 @@ pub struct GetClusterHealthRequest; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterHealthResponse { - /// One of `healthy`, `degraded` or `unavailable`: - /// - healthy: Garage node is connected to all storage nodes - /// - degraded: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions - /// - unavailable: a quorum of write nodes is not available for some partitions + /// One of `healthy`, `degraded` or `unavailable`: + /// - healthy: Garage node is connected to all storage nodes + /// - degraded: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions + /// - unavailable: a quorum of write nodes is not available for some partitions pub status: String, - /// the number of nodes this Garage node has had a TCP connection to since the daemon started + /// the number of nodes this Garage node has had a TCP connection to since the daemon started pub known_nodes: usize, - /// the nubmer of nodes this Garage node currently has an open connection to + /// the nubmer of nodes this Garage node currently has an open connection to pub connected_nodes: usize, - /// the number of storage nodes currently registered in the cluster layout + /// the number of storage nodes currently registered in the cluster layout pub storage_nodes: usize, - /// the number of storage nodes to which a connection is currently open + /// the number of storage nodes to which a connection is currently open pub storage_nodes_ok: usize, - /// the total number of partitions of the data (currently always 256) + /// the total number of partitions of the data (currently always 256) pub partitions: usize, - /// the number of partitions for which a quorum of write nodes is available + /// the number of partitions for which a quorum of write nodes is available pub partitions_quorum: usize, - /// the number of partitions for which we are connected to all storage nodes responsible of storing it + /// the number of partitions for which we are connected to all storage nodes responsible of storing it pub partitions_all_ok: usize, } @@ -463,30 +467,30 @@ pub struct GetBucketInfoRequest { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetBucketInfoResponse { - /// Identifier of the bucket + /// Identifier of the bucket pub id: String, - /// List of global aliases for this bucket + /// List of global aliases for this bucket pub global_aliases: Vec, - /// Whether website acces is enabled for this bucket + /// Whether website acces is enabled for this bucket pub website_access: bool, #[serde(default)] - /// Website configuration for this bucket + /// Website configuration for this bucket pub website_config: Option, - /// List of access keys that have permissions granted on this bucket + /// List of access keys that have permissions granted on this bucket pub keys: Vec, - /// Number of objects in this bucket + /// Number of objects in this bucket pub objects: i64, - /// Total number of bytes used by objects in this bucket + /// Total number of bytes used by objects in this bucket pub bytes: i64, - /// Number of unfinished uploads in this bucket + /// Number of unfinished uploads in this bucket pub unfinished_uploads: i64, - /// Number of unfinished multipart uploads in this bucket + /// Number of unfinished multipart uploads in this bucket pub unfinished_multipart_uploads: i64, - /// Number of parts in unfinished multipart uploads in this bucket + /// Number of parts in unfinished multipart uploads in this bucket pub unfinished_multipart_upload_parts: i64, - /// Total number of bytes used by unfinished multipart uploads in this bucket + /// Total number of bytes used by unfinished multipart uploads in this bucket pub unfinished_multipart_upload_bytes: i64, - /// Quotas that apply to this bucket + /// Quotas that apply to this bucket pub quotas: ApiBucketQuotas, } @@ -573,12 +577,14 @@ pub struct DeleteBucketResponse; // ---- CleanupIncompleteUploads ---- #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct CleanupIncompleteUploadsRequest { pub bucket_id: String, pub older_than_secs: u64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct CleanupIncompleteUploadsResponse { pub uploads_deleted: u64, } @@ -662,7 +668,7 @@ pub struct RemoveBucketAliasResponse(pub GetBucketInfoResponse); #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct LocalGetNodeInfoRequest; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalGetNodeInfoResponse { pub node_id: String, @@ -677,7 +683,7 @@ pub struct LocalGetNodeInfoResponse { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct LocalCreateMetadataSnapshotRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalCreateMetadataSnapshotResponse; // ---- GetNodeStatistics ---- @@ -685,7 +691,7 @@ pub struct LocalCreateMetadataSnapshotResponse; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct LocalGetNodeStatisticsRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalGetNodeStatisticsResponse { pub freeform: String, } @@ -695,19 +701,20 @@ pub struct LocalGetNodeStatisticsResponse { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GetClusterStatisticsRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct GetClusterStatisticsResponse { pub freeform: String, } // ---- LaunchRepairOperation ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct LocalLaunchRepairOperationRequest { pub repair_type: RepairType, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub enum RepairType { Tables, @@ -720,7 +727,7 @@ pub enum RepairType { Scrub(ScrubCommand), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub enum ScrubCommand { Start, @@ -729,16 +736,16 @@ pub enum ScrubCommand { Cancel, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalLaunchRepairOperationResponse; // ********************************************** // Worker operations // ********************************************** -// ---- GetWorkerList ---- +// ---- ListWorkers ---- -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalListWorkersRequest { #[serde(default)] @@ -747,10 +754,10 @@ pub struct LocalListWorkersRequest { pub error_only: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalListWorkersResponse(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct WorkerInfoResp { pub id: u64, @@ -766,51 +773,54 @@ pub struct WorkerInfoResp { pub freeform: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub enum WorkerStateResp { Busy, - Throttled { duration_secs: f32 }, + #[serde(rename_all = "camelCase")] + Throttled { + duration_secs: f32, + }, Idle, Done, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct WorkerLastError { pub message: String, pub secs_ago: u64, } -// ---- GetWorkerList ---- +// ---- GetWorkerInfo ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalGetWorkerInfoRequest { pub id: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalGetWorkerInfoResponse(pub WorkerInfoResp); // ---- GetWorkerVariable ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalGetWorkerVariableRequest { pub variable: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalGetWorkerVariableResponse(pub HashMap); // ---- SetWorkerVariable ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalSetWorkerVariableRequest { pub variable: String, pub value: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalSetWorkerVariableResponse { pub variable: String, pub value: String, @@ -825,10 +835,10 @@ pub struct LocalSetWorkerVariableResponse { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct LocalListBlockErrorsRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalListBlockErrorsResponse(pub Vec); -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] #[serde(rename_all = "camelCase")] pub struct BlockError { pub block_hash: String, @@ -840,13 +850,13 @@ pub struct BlockError { // ---- GetBlockInfo ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalGetBlockInfoRequest { pub block_hash: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalGetBlockInfoResponse { pub block_hash: String, @@ -854,7 +864,7 @@ pub struct LocalGetBlockInfoResponse { pub versions: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct BlockVersion { pub version_id: String, @@ -863,13 +873,12 @@ pub struct BlockVersion { pub backlink: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub enum BlockVersionBacklink { - Object { - bucket_id: String, - key: String, - }, + #[serde(rename_all = "camelCase")] + Object { bucket_id: String, key: String }, + #[serde(rename_all = "camelCase")] Upload { upload_id: String, upload_deleted: bool, @@ -881,7 +890,7 @@ pub enum BlockVersionBacklink { // ---- RetryBlockResync ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(untagged)] pub enum LocalRetryBlockResyncRequest { #[serde(rename_all = "camelCase")] @@ -890,7 +899,7 @@ pub enum LocalRetryBlockResyncRequest { Blocks { block_hashes: Vec }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalRetryBlockResyncResponse { pub count: u64, @@ -898,11 +907,11 @@ pub struct LocalRetryBlockResyncResponse { // ---- PurgeBlocks ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalPurgeBlocksRequest(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocalPurgeBlocksResponse { pub blocks_purged: u64, diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 63f3d36c..5fc2453a 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] #![allow(non_snake_case)] -use utoipa::{OpenApi, Modify}; +use utoipa::{Modify, OpenApi}; use crate::api::*; @@ -11,7 +11,7 @@ use crate::api::*; #[utoipa::path(get, path = "/v2/GetClusterStatus", - tag = "Nodes", + tag = "Cluster", description = " Returns the cluster's current status, including: @@ -31,7 +31,7 @@ fn GetClusterStatus() -> () {} #[utoipa::path(get, path = "/v2/GetClusterHealth", - tag = "Nodes", + tag = "Cluster", description = "Returns the global status of the cluster, the number of connected nodes (over the number of known ones), the number of healthy storage nodes (over the declared ones), and the number of healthy partitions (over the total).", responses( (status = 200, description = "Cluster health report", body = GetClusterHealthResponse), @@ -41,7 +41,7 @@ fn GetClusterHealth() -> () {} #[utoipa::path(post, path = "/v2/ConnectClusterNodes", - tag = "Nodes", + tag = "Cluster", description = "Instructs this Garage node to connect to other Garage nodes at specified `@`. `node_id` is generated automatically on node start.", request_body=ConnectClusterNodesRequest, responses( @@ -53,7 +53,7 @@ fn ConnectClusterNodes() -> () {} #[utoipa::path(get, path = "/v2/GetClusterLayout", - tag = "Layout", + tag = "Cluster layout", description = " Returns the cluster's current layout, including: @@ -72,7 +72,7 @@ fn GetClusterLayout() -> () {} #[utoipa::path(post, path = "/v2/UpdateClusterLayout", - tag = "Layout", + tag = "Cluster layout", description = " Send modifications to the cluster layout. These modifications will be included in the staged role changes, visible in subsequent calls of `GET /GetClusterHealth`. Once the set of staged changes is satisfactory, the user may call `POST /ApplyClusterLayout` to apply the changed changes, or `POST /RevertClusterLayout` to clear all of the staged changes in the layout. @@ -101,7 +101,7 @@ fn UpdateClusterLayout() -> () {} #[utoipa::path(post, path = "/v2/ApplyClusterLayout", - tag = "Layout", + tag = "Cluster layout", description = " Applies to the cluster the layout changes currently registered as staged layout changes. @@ -117,7 +117,7 @@ fn ApplyClusterLayout() -> () {} #[utoipa::path(post, path = "/v2/RevertClusterLayout", - tag = "Layout", + tag = "Cluster layout", description = "Clear staged layout", responses( (status = 200, description = "All pending changes to the cluster layout have been erased", body = RevertClusterLayoutResponse), @@ -132,7 +132,7 @@ fn RevertClusterLayout() -> () {} #[utoipa::path(get, path = "/v2/ListKeys", - tag = "Key", + tag = "Access key", description = "Returns all API access keys in the cluster.", responses( (status = 200, description = "Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string)", body = ListKeysResponse), @@ -143,7 +143,7 @@ fn ListKeys() -> () {} #[utoipa::path(get, path = "/v2/GetKeyInfo", - tag = "Key", + tag = "Access key", description = " Return information about a specific key like its identifiers, its permissions and buckets on which it has permissions. You can search by specifying the exact key identifier (`id`) or by specifying a pattern (`search`). @@ -164,7 +164,7 @@ fn GetKeyInfo() -> () {} #[utoipa::path(post, path = "/v2/CreateKey", - tag = "Key", + tag = "Access key", description = "Creates a new API access key.", request_body = CreateKeyRequest, responses( @@ -176,7 +176,7 @@ fn CreateKey() -> () {} #[utoipa::path(post, path = "/v2/ImportKey", - tag = "Key", + tag = "Access key", description = " Imports an existing API key. This feature must only be used for migrations and backup restore. @@ -192,7 +192,7 @@ fn ImportKey() -> () {} #[utoipa::path(post, path = "/v2/UpdateKey", - tag = "Key", + tag = "Access key", description = " Updates information about the specified API access key. @@ -211,7 +211,7 @@ fn UpdateKey() -> () {} #[utoipa::path(post, path = "/v2/DeleteKey", - tag = "Key", + tag = "Access key", description = "Delete a key from the cluster. Its access will be removed from all the buckets. Buckets are not automatically deleted and can be dangling. You should manually delete them before. ", params( ("id", description = "Access key ID"), @@ -388,7 +388,7 @@ fn DenyBucketKey() -> () {} #[utoipa::path(post, path = "/v2/AddBucketAlias", - tag = "Alias", + tag = "Bucket alias", description = "Add an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", request_body = AddBucketAliasRequest, responses( @@ -400,7 +400,7 @@ fn AddBucketAlias() -> () {} #[utoipa::path(post, path = "/v2/RemoveBucketAlias", - tag = "Alias", + tag = "Bucket alias", description = "Remove an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", request_body = RemoveBucketAliasRequest, responses( @@ -410,6 +410,233 @@ fn AddBucketAlias() -> () {} )] fn RemoveBucketAlias() -> () {} +// ********************************************** +// Node operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/GetNodeInfo", + tag = "Node", + description = " +Return information about the Garage daemon running on one or several nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetNodeInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/CreateMetadataSnapshot", + tag = "Node", + description = " +Instruct one or several nodes to take a snapshot of their metadata databases. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn CreateMetadataSnapshot() -> () {} + +#[utoipa::path(get, + path = "/v2/GetNodeStatistics", + tag = "Node", + description = " +Fetch statistics for one or several Garage nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetNodeStatistics() -> () {} + +#[utoipa::path(get, + path = "/v2/GetClusterStatistics", + tag = "Node", + description = " +Fetch global cluster statistics. + ", + responses( + (status = 200, description = "Global cluster statistics", body = GetClusterStatisticsResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetClusterStatistics() -> () {} + +#[utoipa::path(post, + path = "/v2/LaunchRepairOperation", + tag = "Node", + description = " +Launch a repair operation on one or several cluster noes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalLaunchRepairOperationRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn LaunchRepairOperation() -> () {} + +// ********************************************** +// Worker operations +// ********************************************** + +#[utoipa::path(post, + path = "/v2/ListWorkers", + tag = "Worker", + description = " +List background workers currently running on one or several cluster nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalListWorkersRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ListWorkers() -> () {} + +#[utoipa::path(post, + path = "/v2/GetWorkerInfo", + tag = "Worker", + description = " +Get information about the specified background worker on one or several cluster nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalGetWorkerInfoRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetWorkerInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/GetWorkerVariable", + tag = "Worker", + description = " +Fetch values of one or several worker variables, from one or several cluster nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalGetWorkerVariableRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetWorkerVariable() -> () {} + +#[utoipa::path(post, + path = "/v2/SetWorkerVariable", + tag = "Worker", + description = " +Set the value for a worker variable, on one or several cluster nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalSetWorkerVariableRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn SetWorkerVariable() -> () {} + +// ********************************************** +// Block operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/ListBlockErrors", + tag = "Block", + description = " +List data blocks that are currently in an errored state on one or several Garage nodes. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ListBlockErrors() -> () {} + +#[utoipa::path(post, + path = "/v2/GetBlockInfo", + tag = "Block", + description = " +Get detailed information about a data block stored on a Garage node, including all object versions and in-progress multipart uploads that contain a reference to this block. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalGetBlockInfoRequest, + responses( + (status = 200, description = "Detailed block information", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetBlockInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/RetryBlockResync", + tag = "Block", + description = " +Instruct Garage node(s) to retry the resynchronization of one or several missing data block(s). + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalRetryBlockResyncRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn RetryBlockResync() -> () {} + +#[utoipa::path(post, + path = "/v2/PurgeBlocks", + tag = "Block", + description = " +Purge references to one or several missing data blocks. + +This will remove all objects and in-progress multipart uploads that contain the specified data block(s). The objects will be permanently deleted from the buckets in which they appear. Use with caution. + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + request_body = LocalPurgeBlocksRequest, + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn PurgeBlocks() -> () {} + // ********************************************** // ********************************************** // ********************************************** @@ -417,19 +644,16 @@ fn RemoveBucketAlias() -> () {} struct SecurityAddon; impl Modify for SecurityAddon { - fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - use utoipa::openapi::security::*; - let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. - components.add_security_scheme( - "bearerAuth", - SecurityScheme::Http(Http::builder() - .scheme(HttpAuthScheme::Bearer) - .build()), - ) - } + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::*; + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "bearerAuth", + SecurityScheme::Http(Http::builder().scheme(HttpAuthScheme::Bearer).build()), + ) + } } - #[derive(OpenApi)] #[openapi( info( @@ -475,6 +699,22 @@ impl Modify for SecurityAddon { // Operations on aliases AddBucketAlias, RemoveBucketAlias, + // Node operations + GetNodeInfo, + CreateMetadataSnapshot, + GetNodeStatistics, + GetClusterStatistics, + LaunchRepairOperation, + // Worker operations + ListWorkers, + GetWorkerInfo, + GetWorkerVariable, + SetWorkerVariable, + // Block operations + ListBlockErrors, + GetBlockInfo, + RetryBlockResync, + PurgeBlocks, ), servers( (url = "http://localhost:3903/", description = "A local server") diff --git a/src/garage/main.rs b/src/garage/main.rs index 9e3e3fb6..683042d9 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -152,10 +152,15 @@ async fn main() { Command::Node(NodeOperation::NodeId(node_id_opt)) => { cli::init::node_id_command(opt.config_file, node_id_opt.quiet) } - Command::AdminApiSchema => { - println!("{}", garage_api_admin::openapi::ApiDoc::openapi().to_pretty_json().unwrap()); - Ok(()) - } + Command::AdminApiSchema => { + println!( + "{}", + garage_api_admin::openapi::ApiDoc::openapi() + .to_pretty_json() + .unwrap() + ); + Ok(()) + } _ => cli_command(opt).await, }; From 6b19d7628ebedf837632e9e4c8644bb641c8e92b Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 14:21:25 +0100 Subject: [PATCH 057/192] admin api: small fixes and reordering --- doc/api/garage-admin-v0.html | 2 +- doc/api/garage-admin-v1.html | 2 +- doc/api/garage-admin-v2.html | 2 +- doc/api/garage-admin-v2.json | 39 ++++++++++------- src/api/admin/api.rs | 55 +++++++++++++++--------- src/api/admin/openapi.rs | 82 ++++++++++++++++++++---------------- 6 files changed, 106 insertions(+), 76 deletions(-) diff --git a/doc/api/garage-admin-v0.html b/doc/api/garage-admin-v0.html index dbdd9e1c..4949cc37 100644 --- a/doc/api/garage-admin-v0.html +++ b/doc/api/garage-admin-v0.html @@ -1,7 +1,7 @@ - Garage Adminstration API v0 + Garage adminstration API v0 diff --git a/doc/api/garage-admin-v1.html b/doc/api/garage-admin-v1.html index 783d459e..a9708e92 100644 --- a/doc/api/garage-admin-v1.html +++ b/doc/api/garage-admin-v1.html @@ -1,7 +1,7 @@ - Garage Adminstration API v0 + Garage adminstration API v1 diff --git a/doc/api/garage-admin-v2.html b/doc/api/garage-admin-v2.html index 98f2ed7d..0911f205 100644 --- a/doc/api/garage-admin-v2.html +++ b/doc/api/garage-admin-v2.html @@ -1,7 +1,7 @@ - Garage Adminstration API v0 + Garage adminstration API v2 diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 7b705832..e7b42620 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Garage administration API", - "description": "Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks.\n\n*Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!*", + "description": "Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks.\n\n*Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is early stage and can contain bugs, so be careful and please report any issues on our issue tracker.*", "contact": { "name": "The Garage team", "url": "https://garagehq.deuxfleurs.fr/", @@ -26,7 +26,7 @@ "tags": [ "Bucket alias" ], - "description": "Add an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + "description": "Add an alias for the target bucket. This can be either a global or a local alias, depending on which fields are specified.", "operationId": "AddBucketAlias", "requestBody": { "content": { @@ -493,7 +493,7 @@ "tags": [ "Cluster layout" ], - "description": "\nReturns the cluster's current layout, including:\n\n- Currently configured cluster layout\n- Staged changes to the cluster layout\n\n*Capacity is given in bytes*\n*The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.*\n ", + "description": "\nReturns the cluster's current layout, including:\n\n- Currently configured cluster layout\n- Staged changes to the cluster layout\n\n*Capacity is given in bytes*\n ", "operationId": "GetClusterLayout", "responses": { "200": { @@ -515,9 +515,9 @@ "/v2/GetClusterStatistics": { "get": { "tags": [ - "Node" + "Cluster" ], - "description": "\nFetch global cluster statistics.\n ", + "description": "\nFetch global cluster statistics.\n\n*Note: do not try to parse the `freeform` field of the response, it is given as a string specifically because its format is not stable.*\n ", "operationId": "GetClusterStatistics", "responses": { "200": { @@ -641,7 +641,7 @@ "tags": [ "Node" ], - "description": "\nFetch statistics for one or several Garage nodes.\n ", + "description": "\nFetch statistics for one or several Garage nodes.\n\n*Note: do not try to parse the `freeform` field of the response, it is given as a string specifically because its format is not stable.*\n ", "operationId": "GetNodeStatistics", "parameters": [ { @@ -791,7 +791,7 @@ "tags": [ "Node" ], - "description": "\nLaunch a repair operation on one or several cluster noes.\n ", + "description": "\nLaunch a repair operation on one or several cluster nodes.\n ", "operationId": "LaunchRepairOperation", "parameters": [ { @@ -997,7 +997,7 @@ "tags": [ "Bucket alias" ], - "description": "Remove an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + "description": "Remove an alias for the target bucket. This can be either a global or a local alias, depending on which fields are specified.", "operationId": "RemoveBucketAlias", "requestBody": { "content": { @@ -1073,7 +1073,7 @@ "tags": [ "Cluster layout" ], - "description": "Clear staged layout", + "description": "Clear staged layout changes", "operationId": "RevertClusterLayout", "responses": { "200": { @@ -1598,10 +1598,12 @@ "type": [ "string", "null" - ] + ], + "description": "An error message if Garage did not manage to connect to this node" }, "success": { - "type": "boolean" + "type": "boolean", + "description": "`true` if Garage managed to connect to this node" } } }, @@ -1854,7 +1856,7 @@ }, "status": { "type": "string", - "description": "One of `healthy`, `degraded` or `unavailable`:\n- healthy: Garage node is connected to all storage nodes\n- degraded: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions\n- unavailable: a quorum of write nodes is not available for some partitions" + "description": "One of `healthy`, `degraded` or `unavailable`:\n- `healthy`: Garage node is connected to all storage nodes\n- `degraded`: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions\n- `unavailable`: a quorum of write nodes is not available for some partitions" }, "storageNodes": { "type": "integer", @@ -2883,7 +2885,8 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "description": "ID of the node for which this change applies" } } } @@ -2898,7 +2901,8 @@ ], "properties": { "remove": { - "type": "boolean" + "type": "boolean", + "description": "Set `remove` to `true` to remove the node from the layout" } } }, @@ -2915,16 +2919,19 @@ "null" ], "format": "int64", + "description": "New capacity (in bytes) of the node", "minimum": 0 }, "tags": { "type": "array", "items": { "type": "string" - } + }, + "description": "New tags of the node" }, "zone": { - "type": "string" + "type": "string", + "description": "New zone of the node" } } } diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 9eec880a..4ec62aa9 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -46,7 +46,10 @@ admin_endpoints![ // Cluster operations GetClusterStatus, GetClusterHealth, + GetClusterStatistics, ConnectClusterNodes, + + // Layout operations GetClusterLayout, UpdateClusterLayout, ApplyClusterLayout, @@ -78,9 +81,8 @@ admin_endpoints![ // Node operations GetNodeInfo, - CreateMetadataSnapshot, GetNodeStatistics, - GetClusterStatistics, + CreateMetadataSnapshot, LaunchRepairOperation, // Worker operations @@ -99,8 +101,8 @@ admin_endpoints![ local_admin_endpoints![ // Node operations GetNodeInfo, - CreateMetadataSnapshot, GetNodeStatistics, + CreateMetadataSnapshot, LaunchRepairOperation, // Background workers ListWorkers, @@ -209,9 +211,9 @@ pub struct GetClusterHealthRequest; #[serde(rename_all = "camelCase")] pub struct GetClusterHealthResponse { /// One of `healthy`, `degraded` or `unavailable`: - /// - healthy: Garage node is connected to all storage nodes - /// - degraded: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions - /// - unavailable: a quorum of write nodes is not available for some partitions + /// - `healthy`: Garage node is connected to all storage nodes + /// - `degraded`: Garage node is not connected to all storage nodes, but a quorum of write nodes is available for all partitions + /// - `unavailable`: a quorum of write nodes is not available for some partitions pub status: String, /// the number of nodes this Garage node has had a TCP connection to since the daemon started pub known_nodes: usize, @@ -229,6 +231,16 @@ pub struct GetClusterHealthResponse { pub partitions_all_ok: usize, } +// ---- GetClusterStatistics ---- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GetClusterStatisticsRequest; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GetClusterStatisticsResponse { + pub freeform: String, +} + // ---- ConnectClusterNodes ---- #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -240,10 +252,16 @@ pub struct ConnectClusterNodesResponse(pub Vec); #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ConnectNodeResponse { + /// `true` if Garage managed to connect to this node pub success: bool, + /// An error message if Garage did not manage to connect to this node pub error: Option, } +// ********************************************** +// Layout operations +// ********************************************** + // ---- GetClusterLayout ---- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -260,6 +278,7 @@ pub struct GetClusterLayoutResponse { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeRoleChange { + /// ID of the node for which this change applies pub id: String, #[serde(flatten)] pub action: NodeRoleChangeEnum, @@ -269,11 +288,17 @@ pub struct NodeRoleChange { #[serde(untagged)] pub enum NodeRoleChangeEnum { #[serde(rename_all = "camelCase")] - Remove { remove: bool }, + Remove { + /// Set `remove` to `true` to remove the node from the layout + remove: bool, + }, #[serde(rename_all = "camelCase")] Update { + /// New zone of the node zone: String, + /// New capacity (in bytes) of the node capacity: Option, + /// New tags of the node tags: Vec, }, } @@ -678,14 +703,6 @@ pub struct LocalGetNodeInfoResponse { pub db_engine: String, } -// ---- CreateMetadataSnapshot ---- - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct LocalCreateMetadataSnapshotRequest; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct LocalCreateMetadataSnapshotResponse; - // ---- GetNodeStatistics ---- #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -696,15 +713,13 @@ pub struct LocalGetNodeStatisticsResponse { pub freeform: String, } -// ---- GetClusterStatistics ---- +// ---- CreateMetadataSnapshot ---- #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct GetClusterStatisticsRequest; +pub struct LocalCreateMetadataSnapshotRequest; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct GetClusterStatisticsResponse { - pub freeform: String, -} +pub struct LocalCreateMetadataSnapshotResponse; // ---- LaunchRepairOperation ---- diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 5fc2453a..0e48bf54 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -39,6 +39,21 @@ fn GetClusterStatus() -> () {} )] fn GetClusterHealth() -> () {} +#[utoipa::path(get, + path = "/v2/GetClusterStatistics", + tag = "Cluster", + description = " +Fetch global cluster statistics. + +*Note: do not try to parse the `freeform` field of the response, it is given as a string specifically because its format is not stable.* + ", + responses( + (status = 200, description = "Global cluster statistics", body = GetClusterStatisticsResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetClusterStatistics() -> () {} + #[utoipa::path(post, path = "/v2/ConnectClusterNodes", tag = "Cluster", @@ -51,6 +66,10 @@ fn GetClusterHealth() -> () {} )] fn ConnectClusterNodes() -> () {} +// ********************************************** +// Layout operations +// ********************************************** + #[utoipa::path(get, path = "/v2/GetClusterLayout", tag = "Cluster layout", @@ -61,7 +80,6 @@ Returns the cluster's current layout, including: - Staged changes to the cluster layout *Capacity is given in bytes* -*The info returned by this endpoint is a subset of the info returned by `GET /GetClusterStatus`.* ", responses( (status = 200, description = "Current cluster layout", body = GetClusterLayoutResponse), @@ -118,7 +136,7 @@ fn ApplyClusterLayout() -> () {} #[utoipa::path(post, path = "/v2/RevertClusterLayout", tag = "Cluster layout", - description = "Clear staged layout", + description = "Clear staged layout changes", responses( (status = 200, description = "All pending changes to the cluster layout have been erased", body = RevertClusterLayoutResponse), (status = 500, description = "Internal server error") @@ -389,7 +407,7 @@ fn DenyBucketKey() -> () {} #[utoipa::path(post, path = "/v2/AddBucketAlias", tag = "Bucket alias", - description = "Add an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + description = "Add an alias for the target bucket. This can be either a global or a local alias, depending on which fields are specified.", request_body = AddBucketAliasRequest, responses( (status = 200, description = "Returns exhaustive information about the bucket", body = AddBucketAliasResponse), @@ -401,7 +419,7 @@ fn AddBucketAlias() -> () {} #[utoipa::path(post, path = "/v2/RemoveBucketAlias", tag = "Bucket alias", - description = "Remove an alias for the target bucket. This can be a local alias if `accessKeyId` is specified, or a global alias otherwise.", + description = "Remove an alias for the target bucket. This can be either a global or a local alias, depending on which fields are specified.", request_body = RemoveBucketAliasRequest, responses( (status = 200, description = "Returns exhaustive information about the bucket", body = RemoveBucketAliasResponse), @@ -430,6 +448,24 @@ Return information about the Garage daemon running on one or several nodes. )] fn GetNodeInfo() -> () {} +#[utoipa::path(get, + path = "/v2/GetNodeStatistics", + tag = "Node", + description = " +Fetch statistics for one or several Garage nodes. + +*Note: do not try to parse the `freeform` field of the response, it is given as a string specifically because its format is not stable.* + ", + params( + ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), + ), + responses( + (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetNodeStatistics() -> () {} + #[utoipa::path(post, path = "/v2/CreateMetadataSnapshot", tag = "Node", @@ -446,40 +482,11 @@ Instruct one or several nodes to take a snapshot of their metadata databases. )] fn CreateMetadataSnapshot() -> () {} -#[utoipa::path(get, - path = "/v2/GetNodeStatistics", - tag = "Node", - description = " -Fetch statistics for one or several Garage nodes. - ", - params( - ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), - ), - responses( - (status = 200, description = "Responses from individual cluster nodes", body = MultiResponse), - (status = 500, description = "Internal server error") - ), -)] -fn GetNodeStatistics() -> () {} - -#[utoipa::path(get, - path = "/v2/GetClusterStatistics", - tag = "Node", - description = " -Fetch global cluster statistics. - ", - responses( - (status = 200, description = "Global cluster statistics", body = GetClusterStatisticsResponse), - (status = 500, description = "Internal server error") - ), -)] -fn GetClusterStatistics() -> () {} - #[utoipa::path(post, path = "/v2/LaunchRepairOperation", tag = "Node", description = " -Launch a repair operation on one or several cluster noes. +Launch a repair operation on one or several cluster nodes. ", params( ("node", description = "Node ID to query, or `*` for all nodes, or `self` for the node responding to the request"), @@ -661,7 +668,7 @@ impl Modify for SecurityAddon { title = "Garage administration API", description = "Administrate your Garage cluster programatically, including status, layout, keys, buckets, and maintainance tasks. -*Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is very early stage and can contain bugs, especially on error return codes/types that are not tested yet. Do not expect a well finished and polished product!*", +*Disclaimer: This API may change in future Garage versions. Read the changelog and upgrade your scripts before upgrading. Additionnaly, this specification is early stage and can contain bugs, so be careful and please report any issues on our issue tracker.*", contact( name = "The Garage team", email = "garagehq@deuxfleurs.fr", @@ -674,7 +681,9 @@ impl Modify for SecurityAddon { // Cluster operations GetClusterHealth, GetClusterStatus, + GetClusterStatistics, ConnectClusterNodes, + // Layout operations GetClusterLayout, UpdateClusterLayout, ApplyClusterLayout, @@ -701,9 +710,8 @@ impl Modify for SecurityAddon { RemoveBucketAlias, // Node operations GetNodeInfo, - CreateMetadataSnapshot, GetNodeStatistics, - GetClusterStatistics, + CreateMetadataSnapshot, LaunchRepairOperation, // Worker operations ListWorkers, From e4881e62f116ffc22717f3c46dff84d827f20811 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 17:12:52 +0100 Subject: [PATCH 058/192] admin api: management of layout parameters through admin api --- doc/api/garage-admin-v2.json | 68 +++++++++++++++++++++++++++++-- src/api/admin/api.rs | 28 +++++++++++-- src/api/admin/cluster.rs | 78 +++++++++++++++++++++++++++++++++--- src/api/admin/router_v2.rs | 5 +-- src/garage/cli/layout.rs | 48 ---------------------- src/garage/cli_v2/layout.rs | 50 +++++++++++++++++++---- 6 files changed, 204 insertions(+), 73 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index e7b42620..a13252b3 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1875,15 +1875,29 @@ "required": [ "version", "roles", + "parameters", "stagedRoleChanges" ], "properties": { + "parameters": { + "$ref": "#/components/schemas/LayoutParameters" + }, "roles": { "type": "array", "items": { "$ref": "#/components/schemas/NodeRoleResp" } }, + "stagedParameters": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LayoutParameters" + } + ] + }, "stagedRoleChanges": { "type": "array", "items": { @@ -2021,6 +2035,17 @@ } } }, + "LayoutParameters": { + "type": "object", + "required": [ + "zoneRedundancy" + ], + "properties": { + "zoneRedundancy": { + "$ref": "#/components/schemas/ZoneRedundancy" + } + } + }, "ListBucketsResponse": { "type": "array", "items": { @@ -3109,9 +3134,24 @@ } }, "UpdateClusterLayoutRequest": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NodeRoleChange" + "type": "object", + "properties": { + "parameters": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LayoutParameters" + } + ] + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeRoleChange" + } + } } }, "UpdateClusterLayoutResponse": { @@ -3289,6 +3329,28 @@ ] } ] + }, + "ZoneRedundancy": { + "oneOf": [ + { + "type": "object", + "required": [ + "atLeast" + ], + "properties": { + "atLeast": { + "type": "integer", + "minimum": 0 + } + } + }, + { + "type": "string", + "enum": [ + "maximum" + ] + } + ] } }, "securitySchemes": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 4ec62aa9..0c2d31ab 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -180,9 +180,9 @@ pub struct NodeResp { pub is_up: bool, pub last_seen_secs_ago: Option, pub draining: bool, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub data_partition: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata_partition: Option, } @@ -272,7 +272,9 @@ pub struct GetClusterLayoutRequest; pub struct GetClusterLayoutResponse { pub version: u64, pub roles: Vec, + pub parameters: LayoutParameters, pub staged_role_changes: Vec, + pub staged_parameters: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -303,10 +305,28 @@ pub enum NodeRoleChangeEnum { }, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LayoutParameters { + pub zone_redundancy: ZoneRedundancy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum ZoneRedundancy { + AtLeast(usize), + Maximum, +} + // ---- UpdateClusterLayout ---- #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UpdateClusterLayoutRequest(pub Vec); +pub struct UpdateClusterLayoutRequest { + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub parameters: Option, +} #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); @@ -367,7 +387,7 @@ pub struct GetKeyInfoRequest { pub struct GetKeyInfoResponse { pub name: String, pub access_key_id: String, - #[serde(skip_serializing_if = "is_default")] + #[serde(default, skip_serializing_if = "is_default")] pub secret_access_key: Option, pub permissions: KeyPerm, pub buckets: Vec, diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 13946e2b..485979c4 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -218,10 +218,19 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp }) .collect::>(); + let staged_parameters = if *layout.staging.get().parameters.get() != layout.current().parameters + { + Some((*layout.staging.get().parameters.get()).into()) + } else { + None + }; + GetClusterLayoutResponse { version: layout.current().version, roles, + parameters: layout.current().parameters.into(), staged_role_changes, + staged_parameters, } } @@ -242,7 +251,7 @@ impl RequestHandler for UpdateClusterLayoutRequest { let mut roles = layout.current().roles.clone(); roles.merge(&layout.staging.get().roles); - for change in self.0 { + for change in self.roles { let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?; let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?; @@ -252,11 +261,16 @@ impl RequestHandler for UpdateClusterLayoutRequest { zone, capacity, tags, - } => Some(layout::NodeRole { - zone, - capacity, - tags, - }), + } => { + if matches!(capacity, Some(cap) if cap < 1024) { + return Err(Error::bad_request("Capacity should be at least 1K (1024)")); + } + Some(layout::NodeRole { + zone, + capacity, + tags, + }) + } _ => return Err(Error::bad_request("Invalid layout change")), }; @@ -267,6 +281,22 @@ impl RequestHandler for UpdateClusterLayoutRequest { .merge(&roles.update_mutator(node, layout::NodeRoleV(new_role))); } + if let Some(param) = self.parameters { + if let ZoneRedundancy::AtLeast(r_int) = param.zone_redundancy { + if r_int > layout.current().replication_factor { + return Err(Error::bad_request(format!( + "The zone redundancy must be smaller or equal to the replication factor ({}).", + layout.current().replication_factor + ))); + } else if r_int < 1 { + return Err(Error::bad_request( + "The zone redundancy must be at least 1.", + )); + } + } + layout.staging.get_mut().parameters.update(param.into()); + } + garage .system .layout_manager @@ -322,3 +352,39 @@ impl RequestHandler for RevertClusterLayoutRequest { Ok(RevertClusterLayoutResponse(res)) } } + +// ---- + +impl From for ZoneRedundancy { + fn from(x: layout::ZoneRedundancy) -> Self { + match x { + layout::ZoneRedundancy::Maximum => ZoneRedundancy::Maximum, + layout::ZoneRedundancy::AtLeast(x) => ZoneRedundancy::AtLeast(x), + } + } +} + +impl Into for ZoneRedundancy { + fn into(self) -> layout::ZoneRedundancy { + match self { + ZoneRedundancy::Maximum => layout::ZoneRedundancy::Maximum, + ZoneRedundancy::AtLeast(x) => layout::ZoneRedundancy::AtLeast(x), + } + } +} + +impl From for LayoutParameters { + fn from(x: layout::LayoutParameters) -> Self { + LayoutParameters { + zone_redundancy: x.zone_redundancy.into(), + } + } +} + +impl Into for LayoutParameters { + fn into(self) -> layout::LayoutParameters { + layout::LayoutParameters { + zone_redundancy: self.zone_redundancy.into(), + } + } +} diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 2c2067dc..2397f276 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -108,10 +108,7 @@ impl AdminApiRequest { Endpoint::GetClusterLayout => { Ok(AdminApiRequest::GetClusterLayout(GetClusterLayoutRequest)) } - Endpoint::UpdateClusterLayout => { - let updates = parse_json_body::(req).await?; - Ok(AdminApiRequest::UpdateClusterLayout(updates)) - } + // UpdateClusterLayout semantics changed Endpoint::ApplyClusterLayout => { let param = parse_json_body::(req).await?; Ok(AdminApiRequest::ApplyClusterLayout(param)) diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index bb77cc2a..c93e7a72 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -57,54 +57,6 @@ pub async fn cmd_show_layout( Ok(()) } -pub async fn cmd_config_layout( - rpc_cli: &Endpoint, - rpc_host: NodeID, - config_opt: ConfigLayoutOpt, -) -> Result<(), Error> { - let mut layout = fetch_layout(rpc_cli, rpc_host).await?; - - let mut did_something = false; - match config_opt.redundancy { - None => (), - Some(r_str) => { - let r = r_str - .parse::() - .ok_or_message("invalid zone redundancy value")?; - if let ZoneRedundancy::AtLeast(r_int) = r { - if r_int > layout.current().replication_factor { - return Err(Error::Message(format!( - "The zone redundancy must be smaller or equal to the \ - replication factor ({}).", - layout.current().replication_factor - ))); - } else if r_int < 1 { - return Err(Error::Message( - "The zone redundancy must be at least 1.".into(), - )); - } - } - - layout - .staging - .get_mut() - .parameters - .update(LayoutParameters { zone_redundancy: r }); - println!("The zone redundancy parameter has been set to '{}'.", r); - did_something = true; - } - } - - if !did_something { - return Err(Error::Message( - "Please specify an action for `garage layout config`".into(), - )); - } - - send_layout(rpc_cli, rpc_host, layout).await?; - Ok(()) -} - pub async fn cmd_layout_history( rpc_cli: &Endpoint, rpc_host: NodeID, diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs index 2f14b332..40f3e924 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli_v2/layout.rs @@ -4,6 +4,7 @@ use format_table::format_table; use garage_util::error::*; use garage_api_admin::api::*; +use garage_rpc::layout; use crate::cli::layout as cli_v1; use crate::cli::structs::*; @@ -14,6 +15,7 @@ impl Cli { match cmd { LayoutOperation::Assign(assign_opt) => self.cmd_assign_role(assign_opt).await, LayoutOperation::Remove(remove_opt) => self.cmd_remove_role(remove_opt).await, + LayoutOperation::Config(config_opt) => self.cmd_config_layout(config_opt).await, LayoutOperation::Apply(apply_opt) => self.cmd_apply_layout(apply_opt).await, LayoutOperation::Revert(revert_opt) => self.cmd_revert_layout(revert_opt).await, @@ -21,10 +23,6 @@ impl Cli { LayoutOperation::Show => { cli_v1::cmd_show_layout(&self.system_rpc_endpoint, self.rpc_host).await } - LayoutOperation::Config(config_opt) => { - cli_v1::cmd_config_layout(&self.system_rpc_endpoint, self.rpc_host, config_opt) - .await - } LayoutOperation::History => { cli_v1::cmd_layout_history(&self.system_rpc_endpoint, self.rpc_host).await } @@ -100,8 +98,11 @@ impl Cli { }); } - self.api_request(UpdateClusterLayoutRequest(actions)) - .await?; + self.api_request(UpdateClusterLayoutRequest { + roles: actions, + parameters: None, + }) + .await?; println!("Role changes are staged but not yet committed."); println!("Use `garage layout show` to view staged role changes,"); @@ -126,8 +127,11 @@ impl Cli { action: NodeRoleChangeEnum::Remove { remove: true }, }]; - self.api_request(UpdateClusterLayoutRequest(actions)) - .await?; + self.api_request(UpdateClusterLayoutRequest { + roles: actions, + parameters: None, + }) + .await?; println!("Role removal is staged but not yet committed."); println!("Use `garage layout show` to view staged role changes,"); @@ -135,6 +139,36 @@ impl Cli { Ok(()) } + pub async fn cmd_config_layout(&self, config_opt: ConfigLayoutOpt) -> Result<(), Error> { + let mut did_something = false; + match config_opt.redundancy { + None => (), + Some(r_str) => { + let r = r_str + .parse::() + .ok_or_message("invalid zone redundancy value")?; + + self.api_request(UpdateClusterLayoutRequest { + roles: vec![], + parameters: Some(LayoutParameters { + zone_redundancy: r.into(), + }), + }) + .await?; + println!("The zone redundancy parameter has been set to '{}'.", r); + did_something = true; + } + } + + if !did_something { + return Err(Error::Message( + "Please specify an action for `garage layout config`".into(), + )); + } + + Ok(()) + } + pub async fn cmd_apply_layout(&self, apply_opt: ApplyLayoutOpt) -> Result<(), Error> { let missing_version_error = r#" Please pass the new layout version number to ensure that you are writing the correct version of the cluster layout. From 913e6da41baa260c710477dd79140d6dff73e96e Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 17:26:28 +0100 Subject: [PATCH 059/192] admin api: implement PreviewClusterLayoutChanges --- doc/api/garage-admin-v2.json | 57 ++++++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 18 ++++++++++++ src/api/admin/cluster.rs | 24 +++++++++++++++ src/api/admin/openapi.rs | 16 ++++++++++ src/api/admin/router_v2.rs | 1 + 5 files changed, 116 insertions(+) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index a13252b3..cc2911e5 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -950,6 +950,30 @@ } } }, + "/v2/PreviewClusterLayoutChanges": { + "post": { + "tags": [ + "Cluster layout" + ], + "description": "\nComputes a new layout taking into account the staged parameters, and returns it with detailed statistics. The new layout is not applied in the cluster.\n\n*Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.*\n ", + "operationId": "PreviewClusterLayoutChanges", + "responses": { + "200": { + "description": "Information about the new layout", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewClusterLayoutChangesResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/PurgeBlocks": { "post": { "tags": [ @@ -2992,6 +3016,39 @@ } } }, + "PreviewClusterLayoutChangesResponse": { + "oneOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message", + "newLayout" + ], + "properties": { + "message": { + "type": "array", + "items": { + "type": "string" + } + }, + "newLayout": { + "$ref": "#/components/schemas/GetClusterLayoutResponse" + } + } + } + ] + }, "RemoveBucketAliasRequest": { "allOf": [ { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 0c2d31ab..474225b9 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -52,6 +52,7 @@ admin_endpoints![ // Layout operations GetClusterLayout, UpdateClusterLayout, + PreviewClusterLayoutChanges, ApplyClusterLayout, RevertClusterLayout, @@ -318,6 +319,23 @@ pub enum ZoneRedundancy { Maximum, } +// ---- PreviewClusterLayoutChanges ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreviewClusterLayoutChangesRequest; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum PreviewClusterLayoutChangesResponse { + #[serde(rename_all = "camelCase")] + Error { error: String }, + #[serde(rename_all = "camelCase")] + Success { + message: Vec, + new_layout: GetClusterLayoutResponse, + }, +} + // ---- UpdateClusterLayout ---- #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 485979c4..1cb2a52e 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use garage_util::crdt::*; use garage_util::data::*; +use garage_util::error::Error as GarageError; use garage_rpc::layout; @@ -308,6 +309,29 @@ impl RequestHandler for UpdateClusterLayoutRequest { } } +impl RequestHandler for PreviewClusterLayoutChangesRequest { + type Response = PreviewClusterLayoutChangesResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let layout = garage.system.cluster_layout().inner().clone(); + let new_ver = layout.current().version + 1; + match layout.apply_staged_changes(Some(new_ver)) { + Err(GarageError::Message(error)) => { + Ok(PreviewClusterLayoutChangesResponse::Error { error }) + } + Err(e) => Err(e.into()), + Ok((new_layout, msg)) => Ok(PreviewClusterLayoutChangesResponse::Success { + message: msg, + new_layout: format_cluster_layout(&new_layout), + }), + } + } +} + impl RequestHandler for ApplyClusterLayoutRequest { type Response = ApplyClusterLayoutResponse; diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 0e48bf54..50991c46 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -117,6 +117,21 @@ Contrary to the CLI that may update only a subset of the fields capacity, zone a )] fn UpdateClusterLayout() -> () {} +#[utoipa::path(post, + path = "/v2/PreviewClusterLayoutChanges", + tag = "Cluster layout", + description = " +Computes a new layout taking into account the staged parameters, and returns it with detailed statistics. The new layout is not applied in the cluster. + +*Note: do not try to parse the `message` field of the response, it is given as an array of string specifically because its format is not stable.* + ", + responses( + (status = 200, description = "Information about the new layout", body = PreviewClusterLayoutChangesResponse), + (status = 500, description = "Internal server error") + ), +)] +fn PreviewClusterLayoutChanges() -> () {} + #[utoipa::path(post, path = "/v2/ApplyClusterLayout", tag = "Cluster layout", @@ -686,6 +701,7 @@ impl Modify for SecurityAddon { // Layout operations GetClusterLayout, UpdateClusterLayout, + PreviewClusterLayoutChanges, ApplyClusterLayout, RevertClusterLayout, // Key operations diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 2397f276..e6e6ee91 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -37,6 +37,7 @@ impl AdminApiRequest { // Layout endpoints GET GetClusterLayout (), POST UpdateClusterLayout (body), + POST PreviewClusterLayoutChanges (), POST ApplyClusterLayout (body), POST RevertClusterLayout (), // API key endpoints From 004866caacb731f3c5e438ef80953acdf1626aac Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 17:56:22 +0100 Subject: [PATCH 060/192] admin api, cliv2: implement garage layout show using api functions --- doc/api/garage-admin-v2.json | 108 +++++++++++++++++++--------- src/api/admin/api.rs | 21 ++++-- src/api/admin/cluster.rs | 23 +++--- src/garage/cli/layout.rs | 135 ----------------------------------- src/garage/cli_v2/layout.rs | 105 +++++++++++++++++++++++---- 5 files changed, 198 insertions(+), 194 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index cc2911e5..8c9a83ce 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1899,6 +1899,7 @@ "required": [ "version", "roles", + "partitionSize", "parameters", "stagedRoleChanges" ], @@ -1906,10 +1907,15 @@ "parameters": { "$ref": "#/components/schemas/LayoutParameters" }, + "partitionSize": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, "roles": { "type": "array", "items": { - "$ref": "#/components/schemas/NodeRoleResp" + "$ref": "#/components/schemas/LayoutNodeRole" } }, "stagedParameters": { @@ -2059,6 +2065,44 @@ } } }, + "LayoutNodeRole": { + "type": "object", + "required": [ + "id", + "zone", + "tags" + ], + "properties": { + "capacity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "usableCapacity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "zone": { + "type": "string" + } + } + }, "LayoutParameters": { "type": "object", "required": [ @@ -2853,6 +2897,36 @@ } } }, + "NodeAssignedRole": { + "type": "object", + "required": [ + "id", + "zone", + "tags" + ], + "properties": { + "capacity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "zone": { + "type": "string" + } + } + }, "NodeResp": { "type": "object", "required": [ @@ -2916,7 +2990,7 @@ "type": "null" }, { - "$ref": "#/components/schemas/NodeRoleResp" + "$ref": "#/components/schemas/NodeAssignedRole" } ] } @@ -2986,36 +3060,6 @@ } ] }, - "NodeRoleResp": { - "type": "object", - "required": [ - "id", - "zone", - "tags" - ], - "properties": { - "capacity": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - }, - "id": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "zone": { - "type": "string" - } - } - }, "PreviewClusterLayoutChangesResponse": { "oneOf": [ { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 474225b9..ec448ec2 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -174,7 +174,7 @@ pub struct GetClusterStatusResponse { #[serde(rename_all = "camelCase")] pub struct NodeResp { pub id: String, - pub role: Option, + pub role: Option, #[schema(value_type = Option )] pub addr: Option, pub hostname: Option, @@ -189,7 +189,7 @@ pub struct NodeResp { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct NodeRoleResp { +pub struct NodeAssignedRole { pub id: String, pub zone: String, pub capacity: Option, @@ -272,12 +272,23 @@ pub struct GetClusterLayoutRequest; #[serde(rename_all = "camelCase")] pub struct GetClusterLayoutResponse { pub version: u64, - pub roles: Vec, + pub roles: Vec, + pub partition_size: u64, pub parameters: LayoutParameters, pub staged_role_changes: Vec, pub staged_parameters: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LayoutNodeRole { + pub id: String, + pub zone: String, + pub capacity: Option, + pub usable_capacity: Option, + pub tags: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeRoleChange { @@ -306,13 +317,13 @@ pub enum NodeRoleChangeEnum { }, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Copy, Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LayoutParameters { pub zone_redundancy: ZoneRedundancy, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Copy, Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub enum ZoneRedundancy { AtLeast(usize), diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 1cb2a52e..34cad41f 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -55,7 +55,7 @@ impl RequestHandler for GetClusterStatusRequest { for (id, _, role) in layout.current().roles.items().iter() { if let layout::NodeRoleV(Some(r)) = role { - let role = NodeRoleResp { + let role = NodeAssignedRole { id: hex::encode(id), zone: r.zone.to_string(), capacity: r.capacity, @@ -182,16 +182,21 @@ impl RequestHandler for GetClusterLayoutRequest { } fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResponse { - let roles = layout - .current() + let current = layout.current(); + + let roles = current .roles .items() .iter() .filter_map(|(k, _, v)| v.0.clone().map(|x| (k, x))) - .map(|(k, v)| NodeRoleResp { + .map(|(k, v)| LayoutNodeRole { id: hex::encode(k), zone: v.zone.clone(), capacity: v.capacity, + usable_capacity: current + .get_node_usage(k) + .ok() + .map(|x| x as u64 * current.partition_size), tags: v.tags.clone(), }) .collect::>(); @@ -202,7 +207,7 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp .roles .items() .iter() - .filter(|(k, _, v)| layout.current().roles.get(k) != Some(v)) + .filter(|(k, _, v)| current.roles.get(k) != Some(v)) .map(|(k, _, v)| match &v.0 { None => NodeRoleChange { id: hex::encode(k), @@ -219,17 +224,17 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp }) .collect::>(); - let staged_parameters = if *layout.staging.get().parameters.get() != layout.current().parameters - { + let staged_parameters = if *layout.staging.get().parameters.get() != current.parameters { Some((*layout.staging.get().parameters.get()).into()) } else { None }; GetClusterLayoutResponse { - version: layout.current().version, + version: current.version, roles, - parameters: layout.current().parameters.into(), + partition_size: current.partition_size, + parameters: current.parameters.into(), staged_role_changes, staged_parameters, } diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index c93e7a72..01d413a6 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -1,5 +1,3 @@ -use bytesize::ByteSize; - use format_table::format_table; use garage_util::error::*; @@ -9,54 +7,6 @@ use garage_rpc::*; use crate::cli::structs::*; -pub async fn cmd_show_layout( - rpc_cli: &Endpoint, - rpc_host: NodeID, -) -> Result<(), Error> { - let layout = fetch_layout(rpc_cli, rpc_host).await?; - - println!("==== CURRENT CLUSTER LAYOUT ===="); - print_cluster_layout(layout.current(), "No nodes currently have a role in the cluster.\nSee `garage status` to view available nodes."); - println!(); - println!( - "Current cluster layout version: {}", - layout.current().version - ); - - let has_role_changes = print_staging_role_changes(&layout); - if has_role_changes { - let v = layout.current().version; - let res_apply = layout.apply_staged_changes(Some(v + 1)); - - // this will print the stats of what partitions - // will move around when we apply - match res_apply { - Ok((layout, msg)) => { - println!(); - println!("==== NEW CLUSTER LAYOUT AFTER APPLYING CHANGES ===="); - print_cluster_layout(layout.current(), "No nodes have a role in the new layout."); - println!(); - - for line in msg.iter() { - println!("{}", line); - } - println!("To enact the staged role changes, type:"); - println!(); - println!(" garage layout apply --version {}", v + 1); - println!(); - println!("You can also revert all proposed changes with: garage layout revert"); - } - Err(e) => { - println!("Error while trying to compute the assignment: {}", e); - println!("This new layout cannot yet be applied."); - println!("You can also revert all proposed changes with: garage layout revert"); - } - } - } - - Ok(()) -} - pub async fn cmd_layout_history( rpc_cli: &Endpoint, rpc_host: NodeID, @@ -252,88 +202,3 @@ pub async fn send_layout( .await??; Ok(()) } - -pub fn print_cluster_layout(layout: &LayoutVersion, empty_msg: &str) { - let mut table = vec!["ID\tTags\tZone\tCapacity\tUsable capacity".to_string()]; - for (id, _, role) in layout.roles.items().iter() { - let role = match &role.0 { - Some(r) => r, - _ => continue, - }; - let tags = role.tags.join(","); - let usage = layout.get_node_usage(id).unwrap_or(0); - let capacity = layout.get_node_capacity(id).unwrap_or(0); - if capacity > 0 { - table.push(format!( - "{:?}\t{}\t{}\t{}\t{} ({:.1}%)", - id, - tags, - role.zone, - role.capacity_string(), - ByteSize::b(usage as u64 * layout.partition_size).to_string_as(false), - (100.0 * usage as f32 * layout.partition_size as f32) / (capacity as f32) - )); - } else { - table.push(format!( - "{:?}\t{}\t{}\t{}", - id, - tags, - role.zone, - role.capacity_string() - )); - }; - } - if table.len() > 1 { - format_table(table); - println!(); - println!("Zone redundancy: {}", layout.parameters.zone_redundancy); - } else { - println!("{}", empty_msg); - } -} - -pub fn print_staging_role_changes(layout: &LayoutHistory) -> bool { - let staging = layout.staging.get(); - let has_role_changes = staging - .roles - .items() - .iter() - .any(|(k, _, v)| layout.current().roles.get(k) != Some(v)); - let has_layout_changes = *staging.parameters.get() != layout.current().parameters; - - if has_role_changes || has_layout_changes { - println!(); - println!("==== STAGED ROLE CHANGES ===="); - if has_role_changes { - let mut table = vec!["ID\tTags\tZone\tCapacity".to_string()]; - for (id, _, role) in staging.roles.items().iter() { - if layout.current().roles.get(id) == Some(role) { - continue; - } - if let Some(role) = &role.0 { - let tags = role.tags.join(","); - table.push(format!( - "{:?}\t{}\t{}\t{}", - id, - tags, - role.zone, - role.capacity_string() - )); - } else { - table.push(format!("{:?}\tREMOVED", id)); - } - } - format_table(table); - println!(); - } - if has_layout_changes { - println!( - "Zone redundancy: {}", - staging.parameters.get().zone_redundancy - ); - } - true - } else { - false - } -} diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs index 40f3e924..dfdcccdd 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli_v2/layout.rs @@ -13,6 +13,7 @@ use crate::cli_v2::*; impl Cli { pub async fn layout_command_dispatch(&self, cmd: LayoutOperation) -> Result<(), Error> { match cmd { + LayoutOperation::Show => self.cmd_show_layout().await, LayoutOperation::Assign(assign_opt) => self.cmd_assign_role(assign_opt).await, LayoutOperation::Remove(remove_opt) => self.cmd_remove_role(remove_opt).await, LayoutOperation::Config(config_opt) => self.cmd_config_layout(config_opt).await, @@ -20,9 +21,6 @@ impl Cli { LayoutOperation::Revert(revert_opt) => self.cmd_revert_layout(revert_opt).await, // TODO - LayoutOperation::Show => { - cli_v1::cmd_show_layout(&self.system_rpc_endpoint, self.rpc_host).await - } LayoutOperation::History => { cli_v1::cmd_layout_history(&self.system_rpc_endpoint, self.rpc_host).await } @@ -37,6 +35,50 @@ impl Cli { } } + pub async fn cmd_show_layout(&self) -> Result<(), Error> { + let layout = self.api_request(GetClusterLayoutRequest).await?; + + println!("==== CURRENT CLUSTER LAYOUT ===="); + print_cluster_layout(&layout, "No nodes currently have a role in the cluster.\nSee `garage status` to view available nodes."); + println!(); + println!("Current cluster layout version: {}", layout.version); + + let has_role_changes = print_staging_role_changes(&layout); + if has_role_changes { + let res_apply = self.api_request(PreviewClusterLayoutChangesRequest).await?; + + // this will print the stats of what partitions + // will move around when we apply + match res_apply { + PreviewClusterLayoutChangesResponse::Success { + message, + new_layout, + } => { + println!(); + println!("==== NEW CLUSTER LAYOUT AFTER APPLYING CHANGES ===="); + print_cluster_layout(&new_layout, "No nodes have a role in the new layout."); + println!(); + + for line in message.iter() { + println!("{}", line); + } + println!("To enact the staged role changes, type:"); + println!(); + println!(" garage layout apply --version {}", new_layout.version); + println!(); + println!("You can also revert all proposed changes with: garage layout revert"); + } + PreviewClusterLayoutChangesResponse::Error { error } => { + println!("Error while trying to compute the assignment: {}", error); + println!("This new layout cannot yet be applied."); + println!("You can also revert all proposed changes with: garage layout revert"); + } + } + } + + Ok(()) + } + pub async fn cmd_assign_role(&self, opt: AssignRoleOpt) -> Result<(), Error> { let status = self.api_request(GetClusterStatusRequest).await?; let layout = self.api_request(GetClusterLayoutRequest).await?; @@ -218,7 +260,7 @@ pub fn capacity_string(v: Option) -> String { pub fn get_staged_or_current_role( id: &str, layout: &GetClusterLayoutResponse, -) -> Option { +) -> Option { for node in layout.staged_role_changes.iter() { if node.id == id { return match &node.action { @@ -227,7 +269,7 @@ pub fn get_staged_or_current_role( zone, capacity, tags, - } => Some(NodeRoleResp { + } => Some(NodeAssignedRole { id: id.to_string(), zone: zone.to_string(), capacity: *capacity, @@ -239,7 +281,12 @@ pub fn get_staged_or_current_role( for node in layout.roles.iter() { if node.id == id { - return Some(node.clone()); + return Some(NodeAssignedRole { + id: node.id.clone(), + zone: node.zone.clone(), + capacity: node.capacity, + tags: node.tags.clone(), + }); } } @@ -267,11 +314,46 @@ pub fn find_matching_node<'a>( } } +pub fn print_cluster_layout(layout: &GetClusterLayoutResponse, empty_msg: &str) { + let mut table = vec!["ID\tTags\tZone\tCapacity\tUsable capacity".to_string()]; + for role in layout.roles.iter() { + let tags = role.tags.join(","); + if let (Some(capacity), Some(usable_capacity)) = (role.capacity, role.usable_capacity) { + table.push(format!( + "{:.16}\t{}\t{}\t{}\t{} ({:.1}%)", + role.id, + tags, + role.zone, + capacity_string(role.capacity), + ByteSize::b(usable_capacity).to_string_as(false), + (100.0 * usable_capacity as f32) / (capacity as f32) + )); + } else { + table.push(format!( + "{:.16}\t{}\t{}\t{}", + role.id, + tags, + role.zone, + capacity_string(role.capacity), + )); + }; + } + if table.len() > 1 { + format_table(table); + println!(); + println!( + "Zone redundancy: {}", + Into::::into(layout.parameters.zone_redundancy) + ); + } else { + println!("{}", empty_msg); + } +} + pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { let has_role_changes = !layout.staged_role_changes.is_empty(); - // TODO!! Layout parameters - let has_layout_changes = false; + let has_layout_changes = layout.staged_parameters.is_some(); if has_role_changes || has_layout_changes { println!(); @@ -302,15 +384,12 @@ pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { format_table(table); println!(); } - //TODO - /* - if has_layout_changes { + if let Some(p) = layout.staged_parameters.as_ref() { println!( "Zone redundancy: {}", - staging.parameters.get().zone_redundancy + Into::::into(p.zone_redundancy) ); } - */ true } else { false From 3d94eb8d4bceae11dc0fbb11217d95ff1fb27179 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 18:33:05 +0100 Subject: [PATCH 061/192] admin api: implement GetClusterLayoutHistory and use it in CLI --- doc/api/garage-admin-v2.json | 124 +++++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 65 ++++++++++++++---- src/api/admin/cluster.rs | 83 +++++++++++++++++++++++ src/api/admin/openapi.rs | 14 ++++ src/api/admin/router_v2.rs | 1 + src/garage/cli/layout.rs | 101 +--------------------------- src/garage/cli_v2/layout.rs | 66 ++++++++++++++++++- 7 files changed, 340 insertions(+), 114 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 8c9a83ce..598f82a3 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -512,6 +512,30 @@ } } }, + "/v2/GetClusterLayoutHistory": { + "get": { + "tags": [ + "Cluster layout" + ], + "description": "\nReturns the history of layouts in the cluster\n ", + "operationId": "GetClusterLayoutHistory", + "responses": { + "200": { + "description": "Cluster layout history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetClusterLayoutHistoryResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/GetClusterStatistics": { "get": { "tags": [ @@ -1600,6 +1624,43 @@ } } }, + "ClusterLayoutVersion": { + "type": "object", + "required": [ + "version", + "status", + "storageNodes", + "gatewayNodes" + ], + "properties": { + "gatewayNodes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/ClusterLayoutVersionStatus" + }, + "storageNodes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ClusterLayoutVersionStatus": { + "type": "string", + "enum": [ + "Current", + "Draining", + "Historical" + ] + }, "ConnectClusterNodesRequest": { "type": "array", "items": { @@ -1894,6 +1955,44 @@ } } }, + "GetClusterLayoutHistoryResponse": { + "type": "object", + "required": [ + "currentVersion", + "minAck", + "versions" + ], + "properties": { + "currentVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "minAck": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "updateTrackers": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/components/schemas/NodeUpdateTrackers" + }, + "propertyNames": { + "type": "string" + } + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClusterLayoutVersion" + } + } + } + }, "GetClusterLayoutResponse": { "type": "object", "required": [ @@ -3060,6 +3159,31 @@ } ] }, + "NodeUpdateTrackers": { + "type": "object", + "required": [ + "ack", + "sync", + "syncAck" + ], + "properties": { + "ack": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "sync": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "syncAck": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "PreviewClusterLayoutChangesResponse": { "oneOf": [ { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index ec448ec2..ea017f7b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -51,6 +51,7 @@ admin_endpoints![ // Layout operations GetClusterLayout, + GetClusterLayoutHistory, UpdateClusterLayout, PreviewClusterLayoutChanges, ApplyClusterLayout, @@ -330,6 +331,57 @@ pub enum ZoneRedundancy { Maximum, } +// ---- GetClusterLayoutHistory ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetClusterLayoutHistoryRequest; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterLayoutHistoryResponse { + pub current_version: u64, + pub min_ack: u64, + pub versions: Vec, + pub update_trackers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ClusterLayoutVersion { + pub version: u64, + pub status: ClusterLayoutVersionStatus, + pub storage_nodes: u64, + pub gateway_nodes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub enum ClusterLayoutVersionStatus { + Current, + Draining, + Historical, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NodeUpdateTrackers { + pub ack: u64, + pub sync: u64, + pub sync_ack: u64, +} + +// ---- UpdateClusterLayout ---- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateClusterLayoutRequest { + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub parameters: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); + // ---- PreviewClusterLayoutChanges ---- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -347,19 +399,6 @@ pub enum PreviewClusterLayoutChangesResponse { }, } -// ---- UpdateClusterLayout ---- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UpdateClusterLayoutRequest { - #[serde(default)] - pub roles: Vec, - #[serde(default)] - pub parameters: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse); - // ---- ApplyClusterLayout ---- #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 34cad41f..3c076064 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -240,6 +240,89 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp } } +impl RequestHandler for GetClusterLayoutHistoryRequest { + type Response = GetClusterLayoutHistoryResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let layout = garage.system.cluster_layout(); + let layout = layout.inner(); + let min_stored = layout.min_stored(); + + let versions = layout + .versions + .iter() + .rev() + .chain(layout.old_versions.iter().rev()) + .map(|ver| { + let status = if ver.version == layout.current().version { + ClusterLayoutVersionStatus::Current + } else if ver.version >= min_stored { + ClusterLayoutVersionStatus::Draining + } else { + ClusterLayoutVersionStatus::Historical + }; + ClusterLayoutVersion { + version: ver.version, + status, + storage_nodes: ver + .roles + .items() + .iter() + .filter( + |(_, _, x)| matches!(x, layout::NodeRoleV(Some(c)) if c.capacity.is_some()), + ) + .count() as u64, + gateway_nodes: ver + .roles + .items() + .iter() + .filter( + |(_, _, x)| matches!(x, layout::NodeRoleV(Some(c)) if c.capacity.is_none()), + ) + .count() as u64, + } + }) + .collect::>(); + + let all_nodes = layout.get_all_nodes(); + let min_ack = layout + .update_trackers + .ack_map + .min_among(&all_nodes, layout.min_stored()); + + let update_trackers = if layout.versions.len() > 1 { + Some( + all_nodes + .iter() + .map(|node| { + ( + hex::encode(&node), + NodeUpdateTrackers { + ack: layout.update_trackers.ack_map.get(node, min_stored), + sync: layout.update_trackers.sync_map.get(node, min_stored), + sync_ack: layout.update_trackers.sync_ack_map.get(node, min_stored), + }, + ) + }) + .collect(), + ) + } else { + None + }; + + Ok(GetClusterLayoutHistoryResponse { + current_version: layout.current().version, + min_ack, + versions, + update_trackers, + }) + } +} + // ---- // ---- update functions ---- diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 50991c46..0a31449b 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -88,6 +88,19 @@ Returns the cluster's current layout, including: )] fn GetClusterLayout() -> () {} +#[utoipa::path(get, + path = "/v2/GetClusterLayoutHistory", + tag = "Cluster layout", + description = " +Returns the history of layouts in the cluster + ", + responses( + (status = 200, description = "Cluster layout history", body = GetClusterLayoutHistoryResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetClusterLayoutHistory() -> () {} + #[utoipa::path(post, path = "/v2/UpdateClusterLayout", tag = "Cluster layout", @@ -700,6 +713,7 @@ impl Modify for SecurityAddon { ConnectClusterNodes, // Layout operations GetClusterLayout, + GetClusterLayoutHistory, UpdateClusterLayout, PreviewClusterLayoutChanges, ApplyClusterLayout, diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index e6e6ee91..318e7173 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -36,6 +36,7 @@ impl AdminApiRequest { POST ConnectClusterNodes (body), // Layout endpoints GET GetClusterLayout (), + GET GetClusterLayoutHistory (), POST UpdateClusterLayout (body), POST PreviewClusterLayoutChanges (), POST ApplyClusterLayout (body), diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs index 01d413a6..352f792b 100644 --- a/src/garage/cli/layout.rs +++ b/src/garage/cli/layout.rs @@ -1,4 +1,3 @@ -use format_table::format_table; use garage_util::error::*; use garage_rpc::layout::*; @@ -7,100 +6,6 @@ use garage_rpc::*; use crate::cli::structs::*; -pub async fn cmd_layout_history( - rpc_cli: &Endpoint, - rpc_host: NodeID, -) -> Result<(), Error> { - let layout = fetch_layout(rpc_cli, rpc_host).await?; - let min_stored = layout.min_stored(); - - println!("==== LAYOUT HISTORY ===="); - let mut table = vec!["Version\tStatus\tStorage nodes\tGateway nodes".to_string()]; - for ver in layout - .versions - .iter() - .rev() - .chain(layout.old_versions.iter().rev()) - { - let status = if ver.version == layout.current().version { - "current" - } else if ver.version >= min_stored { - "draining" - } else { - "historical" - }; - table.push(format!( - "#{}\t{}\t{}\t{}", - ver.version, - status, - ver.roles - .items() - .iter() - .filter(|(_, _, x)| matches!(x, NodeRoleV(Some(c)) if c.capacity.is_some())) - .count(), - ver.roles - .items() - .iter() - .filter(|(_, _, x)| matches!(x, NodeRoleV(Some(c)) if c.capacity.is_none())) - .count(), - )); - } - format_table(table); - println!(); - - if layout.versions.len() > 1 { - println!("==== UPDATE TRACKERS ===="); - println!("Several layout versions are currently live in the cluster, and data is being migrated."); - println!( - "This is the internal data that Garage stores to know which nodes have what data." - ); - println!(); - let mut table = vec!["Node\tAck\tSync\tSync_ack".to_string()]; - let all_nodes = layout.get_all_nodes(); - for node in all_nodes.iter() { - table.push(format!( - "{:?}\t#{}\t#{}\t#{}", - node, - layout.update_trackers.ack_map.get(node, min_stored), - layout.update_trackers.sync_map.get(node, min_stored), - layout.update_trackers.sync_ack_map.get(node, min_stored), - )); - } - table[1..].sort(); - format_table(table); - - let min_ack = layout - .update_trackers - .ack_map - .min_among(&all_nodes, layout.min_stored()); - - println!(); - println!( - "If some nodes are not catching up to the latest layout version in the update trackers," - ); - println!("it might be because they are offline or unable to complete a sync successfully."); - if min_ack < layout.current().version { - println!( - "You may force progress using `garage layout skip-dead-nodes --version {}`", - layout.current().version - ); - } else { - println!( - "You may force progress using `garage layout skip-dead-nodes --version {} --allow-missing-data`.", - layout.current().version - ); - } - } else { - println!("Your cluster is currently in a stable state with a single live layout version."); - println!("No metadata migration is in progress. Note that the migration of data blocks is not tracked,"); - println!( - "so you might want to keep old nodes online until their data directories become empty." - ); - } - - Ok(()) -} - pub async fn cmd_layout_skip_dead_nodes( rpc_cli: &Endpoint, rpc_host: NodeID, @@ -162,7 +67,7 @@ pub async fn cmd_layout_skip_dead_nodes( // --- utility --- -pub async fn fetch_status( +async fn fetch_status( rpc_cli: &Endpoint, rpc_host: NodeID, ) -> Result, Error> { @@ -175,7 +80,7 @@ pub async fn fetch_status( } } -pub async fn fetch_layout( +async fn fetch_layout( rpc_cli: &Endpoint, rpc_host: NodeID, ) -> Result { @@ -188,7 +93,7 @@ pub async fn fetch_layout( } } -pub async fn send_layout( +async fn send_layout( rpc_cli: &Endpoint, rpc_host: NodeID, layout: LayoutHistory, diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs index dfdcccdd..250cd0b0 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli_v2/layout.rs @@ -19,11 +19,9 @@ impl Cli { LayoutOperation::Config(config_opt) => self.cmd_config_layout(config_opt).await, LayoutOperation::Apply(apply_opt) => self.cmd_apply_layout(apply_opt).await, LayoutOperation::Revert(revert_opt) => self.cmd_revert_layout(revert_opt).await, + LayoutOperation::History => self.cmd_layout_history().await, // TODO - LayoutOperation::History => { - cli_v1::cmd_layout_history(&self.system_rpc_endpoint, self.rpc_host).await - } LayoutOperation::SkipDeadNodes(assume_sync_opt) => { cli_v1::cmd_layout_skip_dead_nodes( &self.system_rpc_endpoint, @@ -244,6 +242,68 @@ To know the correct value of the new layout version, invoke `garage layout show` println!("All proposed role changes in cluster layout have been canceled."); Ok(()) } + + pub async fn cmd_layout_history(&self) -> Result<(), Error> { + let history = self.api_request(GetClusterLayoutHistoryRequest).await?; + + println!("==== LAYOUT HISTORY ===="); + let mut table = vec!["Version\tStatus\tStorage nodes\tGateway nodes".to_string()]; + for ver in history.versions.iter() { + table.push(format!( + "#{}\t{:?}\t{}\t{}", + ver.version, ver.status, ver.storage_nodes, ver.gateway_nodes, + )); + } + format_table(table); + println!(); + + if let Some(update_trackers) = history.update_trackers { + println!("==== UPDATE TRACKERS ===="); + println!("Several layout versions are currently live in the cluster, and data is being migrated."); + println!( + "This is the internal data that Garage stores to know which nodes have what data." + ); + println!(); + let mut table = vec!["Node\tAck\tSync\tSync_ack".to_string()]; + for (node, trackers) in update_trackers.iter() { + table.push(format!( + "{:.16}\t#{}\t#{}\t#{}", + node, trackers.ack, trackers.sync, trackers.sync_ack, + )); + } + table[1..].sort(); + format_table(table); + + println!(); + println!( + "If some nodes are not catching up to the latest layout version in the update trackers," + ); + println!( + "it might be because they are offline or unable to complete a sync successfully." + ); + if history.min_ack < history.current_version { + println!( + "You may force progress using `garage layout skip-dead-nodes --version {}`", + history.current_version + ); + } else { + println!( + "You may force progress using `garage layout skip-dead-nodes --version {} --allow-missing-data`.", + history.current_version + ); + } + } else { + println!( + "Your cluster is currently in a stable state with a single live layout version." + ); + println!("No metadata migration is in progress. Note that the migration of data blocks is not tracked,"); + println!( + "so you might want to keep old nodes online until their data directories become empty." + ); + } + + Ok(()) + } } // -------------------------- From 0951b5db75e2576a004718b5cdcfe66ce7d70028 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 18:49:56 +0100 Subject: [PATCH 062/192] admin api: implement ClusterLayoutSkipDeadNodes and use it in CLI --- doc/api/garage-admin-v2.json | 72 +++++++++++++++++++++++ src/api/admin/api.rs | 17 ++++++ src/api/admin/cluster.rs | 61 ++++++++++++++++++++ src/api/admin/openapi.rs | 13 +++++ src/api/admin/router_v2.rs | 1 + src/garage/cli/layout.rs | 109 ----------------------------------- src/garage/cli/mod.rs | 2 - src/garage/cli_v2/layout.rs | 37 ++++++++---- src/garage/cli_v2/mod.rs | 2 - src/garage/main.rs | 2 - 10 files changed, 190 insertions(+), 126 deletions(-) delete mode 100644 src/garage/cli/layout.rs diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 598f82a3..921d8d4c 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -157,6 +157,40 @@ } } }, + "/v2/ClusterLayoutSkipDeadNodes": { + "post": { + "tags": [ + "Cluster layout" + ], + "description": "Force progress in layout update trackers", + "operationId": "ClusterLayoutSkipDeadNodes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterLayoutSkipDeadNodesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Request has been taken into account", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterLayoutSkipDeadNodesResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/ConnectClusterNodes": { "post": { "tags": [ @@ -1624,6 +1658,44 @@ } } }, + "ClusterLayoutSkipDeadNodesRequest": { + "type": "object", + "required": [ + "version", + "allowMissingData" + ], + "properties": { + "allowMissingData": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ClusterLayoutSkipDeadNodesResponse": { + "type": "object", + "required": [ + "ackUpdated", + "syncUpdated" + ], + "properties": { + "ackUpdated": { + "type": "array", + "items": { + "type": "string" + } + }, + "syncUpdated": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ClusterLayoutVersion": { "type": "object", "required": [ diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index ea017f7b..ec0a9e3c 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -56,6 +56,7 @@ admin_endpoints![ PreviewClusterLayoutChanges, ApplyClusterLayout, RevertClusterLayout, + ClusterLayoutSkipDeadNodes, // Access key operations ListKeys, @@ -422,6 +423,22 @@ pub struct RevertClusterLayoutRequest; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); +// ---- ClusterLayoutSkipDeadNodes ---- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ClusterLayoutSkipDeadNodesRequest { + pub version: u64, + pub allow_missing_data: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ClusterLayoutSkipDeadNodesResponse { + pub ack_updated: Vec, + pub sync_updated: Vec, +} + // ********************************************** // Access key operations // ********************************************** diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 3c076064..8171aa98 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -465,6 +465,67 @@ impl RequestHandler for RevertClusterLayoutRequest { } } +impl RequestHandler for ClusterLayoutSkipDeadNodesRequest { + type Response = ClusterLayoutSkipDeadNodesResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let status = garage.system.get_known_nodes(); + + let mut layout = garage.system.cluster_layout().inner().clone(); + let mut ack_updated = vec![]; + let mut sync_updated = vec![]; + + if layout.versions.len() == 1 { + return Err(Error::bad_request( + "This command cannot be called when there is only one live cluster layout version", + )); + } + + let min_v = layout.min_stored(); + if self.version <= min_v || self.version > layout.current().version { + return Err(Error::bad_request(format!( + "Invalid version, you may use the following version numbers: {}", + (min_v + 1..=layout.current().version) + .map(|x| x.to_string()) + .collect::>() + .join(" ") + ))); + } + + let all_nodes = layout.get_all_nodes(); + for node in all_nodes.iter() { + // Update ACK tracker for dead nodes or for all nodes if --allow-missing-data + if self.allow_missing_data || !status.iter().any(|x| x.id == *node && x.is_up) { + if layout.update_trackers.ack_map.set_max(*node, self.version) { + ack_updated.push(hex::encode(node)); + } + } + + // If --allow-missing-data, update SYNC tracker for all nodes. + if self.allow_missing_data { + if layout.update_trackers.sync_map.set_max(*node, self.version) { + sync_updated.push(hex::encode(node)); + } + } + } + + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + Ok(ClusterLayoutSkipDeadNodesResponse { + ack_updated, + sync_updated, + }) + } +} + // ---- impl From for ZoneRedundancy { diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 0a31449b..01a694e5 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -172,6 +172,18 @@ fn ApplyClusterLayout() -> () {} )] fn RevertClusterLayout() -> () {} +#[utoipa::path(post, + path = "/v2/ClusterLayoutSkipDeadNodes", + tag = "Cluster layout", + description = "Force progress in layout update trackers", + request_body = ClusterLayoutSkipDeadNodesRequest, + responses( + (status = 200, description = "Request has been taken into account", body = ClusterLayoutSkipDeadNodesResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ClusterLayoutSkipDeadNodes() -> () {} + // ********************************************** // Access key operations // ********************************************** @@ -718,6 +730,7 @@ impl Modify for SecurityAddon { PreviewClusterLayoutChanges, ApplyClusterLayout, RevertClusterLayout, + ClusterLayoutSkipDeadNodes, // Key operations ListKeys, GetKeyInfo, diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 318e7173..9f6106e5 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -41,6 +41,7 @@ impl AdminApiRequest { POST PreviewClusterLayoutChanges (), POST ApplyClusterLayout (body), POST RevertClusterLayout (), + POST ClusterLayoutSkipDeadNodes (body), // API key endpoints GET GetKeyInfo (query_opt::id, query_opt::search, parse_default(false)::show_secret_key), POST UpdateKey (body_field, query::id), diff --git a/src/garage/cli/layout.rs b/src/garage/cli/layout.rs deleted file mode 100644 index 352f792b..00000000 --- a/src/garage/cli/layout.rs +++ /dev/null @@ -1,109 +0,0 @@ -use garage_util::error::*; - -use garage_rpc::layout::*; -use garage_rpc::system::*; -use garage_rpc::*; - -use crate::cli::structs::*; - -pub async fn cmd_layout_skip_dead_nodes( - rpc_cli: &Endpoint, - rpc_host: NodeID, - opt: SkipDeadNodesOpt, -) -> Result<(), Error> { - let status = fetch_status(rpc_cli, rpc_host).await?; - let mut layout = fetch_layout(rpc_cli, rpc_host).await?; - - if layout.versions.len() == 1 { - return Err(Error::Message( - "This command cannot be called when there is only one live cluster layout version" - .into(), - )); - } - - let min_v = layout.min_stored(); - if opt.version <= min_v || opt.version > layout.current().version { - return Err(Error::Message(format!( - "Invalid version, you may use the following version numbers: {}", - (min_v + 1..=layout.current().version) - .map(|x| x.to_string()) - .collect::>() - .join(" ") - ))); - } - - let all_nodes = layout.get_all_nodes(); - let mut did_something = false; - for node in all_nodes.iter() { - // Update ACK tracker for dead nodes or for all nodes if --allow-missing-data - if opt.allow_missing_data || !status.iter().any(|x| x.id == *node && x.is_up) { - if layout.update_trackers.ack_map.set_max(*node, opt.version) { - println!("Increased the ACK tracker for node {:?}", node); - did_something = true; - } - } - - // If --allow-missing-data, update SYNC tracker for all nodes. - if opt.allow_missing_data { - if layout.update_trackers.sync_map.set_max(*node, opt.version) { - println!("Increased the SYNC tracker for node {:?}", node); - did_something = true; - } - } - } - - if did_something { - send_layout(rpc_cli, rpc_host, layout).await?; - println!("Success."); - Ok(()) - } else if !opt.allow_missing_data { - Err(Error::Message("Nothing was done, try passing the `--allow-missing-data` flag to force progress even when not enough nodes can complete a metadata sync.".into())) - } else { - Err(Error::Message( - "Sorry, there is nothing I can do for you. Please wait patiently. If you ask for help, please send the output of the `garage layout history` command.".into(), - )) - } -} - -// --- utility --- - -async fn fetch_status( - rpc_cli: &Endpoint, - rpc_host: NodeID, -) -> Result, Error> { - match rpc_cli - .call(&rpc_host, SystemRpc::GetKnownNodes, PRIO_NORMAL) - .await?? - { - SystemRpc::ReturnKnownNodes(nodes) => Ok(nodes), - resp => Err(Error::unexpected_rpc_message(resp)), - } -} - -async fn fetch_layout( - rpc_cli: &Endpoint, - rpc_host: NodeID, -) -> Result { - match rpc_cli - .call(&rpc_host, SystemRpc::PullClusterLayout, PRIO_NORMAL) - .await?? - { - SystemRpc::AdvertiseClusterLayout(t) => Ok(t), - resp => Err(Error::unexpected_rpc_message(resp)), - } -} - -async fn send_layout( - rpc_cli: &Endpoint, - rpc_host: NodeID, - layout: LayoutHistory, -) -> Result<(), Error> { - rpc_cli - .call( - &rpc_host, - SystemRpc::AdvertiseClusterLayout(layout), - PRIO_NORMAL, - ) - .await??; - Ok(()) -} diff --git a/src/garage/cli/mod.rs b/src/garage/cli/mod.rs index e007808b..146fac56 100644 --- a/src/garage/cli/mod.rs +++ b/src/garage/cli/mod.rs @@ -3,5 +3,3 @@ pub(crate) mod structs; pub(crate) mod convert_db; pub(crate) mod init; pub(crate) mod repair; - -pub(crate) mod layout; diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli_v2/layout.rs index 250cd0b0..bab6f28e 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli_v2/layout.rs @@ -6,7 +6,6 @@ use garage_util::error::*; use garage_api_admin::api::*; use garage_rpc::layout; -use crate::cli::layout as cli_v1; use crate::cli::structs::*; use crate::cli_v2::*; @@ -20,16 +19,7 @@ impl Cli { LayoutOperation::Apply(apply_opt) => self.cmd_apply_layout(apply_opt).await, LayoutOperation::Revert(revert_opt) => self.cmd_revert_layout(revert_opt).await, LayoutOperation::History => self.cmd_layout_history().await, - - // TODO - LayoutOperation::SkipDeadNodes(assume_sync_opt) => { - cli_v1::cmd_layout_skip_dead_nodes( - &self.system_rpc_endpoint, - self.rpc_host, - assume_sync_opt, - ) - .await - } + LayoutOperation::SkipDeadNodes(opt) => self.cmd_skip_dead_nodes(opt).await, } } @@ -304,6 +294,31 @@ To know the correct value of the new layout version, invoke `garage layout show` Ok(()) } + + pub async fn cmd_skip_dead_nodes(&self, opt: SkipDeadNodesOpt) -> Result<(), Error> { + let res = self + .api_request(ClusterLayoutSkipDeadNodesRequest { + version: opt.version, + allow_missing_data: opt.allow_missing_data, + }) + .await?; + + if !res.sync_updated.is_empty() || !res.ack_updated.is_empty() { + for node in res.ack_updated.iter() { + println!("Increased the ACK tracker for node {:.16}", node); + } + for node in res.sync_updated.iter() { + println!("Increased the SYNC tracker for node {:.16}", node); + } + Ok(()) + } else if !opt.allow_missing_data { + Err(Error::Message("Nothing was done, try passing the `--allow-missing-data` flag to force progress even when not enough nodes can complete a metadata sync.".into())) + } else { + Err(Error::Message( + "Sorry, there is nothing I can do for you. Please wait patiently. If you ask for help, please send the output of the `garage layout history` command.".into(), + )) + } + } } // -------------------------- diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli_v2/mod.rs index 28c7c824..40673b91 100644 --- a/src/garage/cli_v2/mod.rs +++ b/src/garage/cli_v2/mod.rs @@ -13,7 +13,6 @@ use std::time::Duration; use garage_util::error::*; -use garage_rpc::system::*; use garage_rpc::*; use garage_api_admin::api::*; @@ -23,7 +22,6 @@ use garage_api_admin::RequestHandler; use crate::cli::structs::*; pub struct Cli { - pub system_rpc_endpoint: Arc>, pub proxy_rpc_endpoint: Arc>, pub rpc_host: NodeID, } diff --git a/src/garage/main.rs b/src/garage/main.rs index 683042d9..5d392c44 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -289,11 +289,9 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { Err(e).err_context("Unable to connect to destination RPC host. Check that you are using the same value of rpc_secret as them, and that you have their correct full-length node ID (public key).")?; } - let system_rpc_endpoint = netapp.endpoint::(SYSTEM_RPC_PATH.into()); let proxy_rpc_endpoint = netapp.endpoint::(PROXY_RPC_PATH.into()); let cli = cli_v2::Cli { - system_rpc_endpoint, proxy_rpc_endpoint, rpc_host: id, }; From cd0728cd208341dfba03807179f207368e677ddd Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 6 Mar 2025 18:54:40 +0100 Subject: [PATCH 063/192] cli: move files around --- src/garage/admin/mod.rs | 540 ------------------- src/garage/cli/{ => local}/convert_db.rs | 0 src/garage/cli/{ => local}/init.rs | 0 src/garage/cli/local/mod.rs | 3 + src/garage/cli/{ => local}/repair.rs | 0 src/garage/cli/mod.rs | 7 +- src/garage/{cli_v2 => cli/remote}/block.rs | 2 +- src/garage/{cli_v2 => cli/remote}/bucket.rs | 2 +- src/garage/{cli_v2 => cli/remote}/cluster.rs | 4 +- src/garage/{cli_v2 => cli/remote}/key.rs | 2 +- src/garage/{cli_v2 => cli/remote}/layout.rs | 2 +- src/garage/{cli_v2 => cli/remote}/mod.rs | 0 src/garage/{cli_v2 => cli/remote}/node.rs | 2 +- src/garage/{cli_v2 => cli/remote}/worker.rs | 2 +- src/garage/cli/structs.rs | 2 +- src/garage/main.rs | 11 +- 16 files changed, 20 insertions(+), 559 deletions(-) delete mode 100644 src/garage/admin/mod.rs rename src/garage/cli/{ => local}/convert_db.rs (100%) rename src/garage/cli/{ => local}/init.rs (100%) create mode 100644 src/garage/cli/local/mod.rs rename src/garage/cli/{ => local}/repair.rs (100%) rename src/garage/{cli_v2 => cli/remote}/block.rs (99%) rename src/garage/{cli_v2 => cli/remote}/bucket.rs (99%) rename src/garage/{cli_v2 => cli/remote}/cluster.rs (98%) rename src/garage/{cli_v2 => cli/remote}/key.rs (99%) rename src/garage/{cli_v2 => cli/remote}/layout.rs (99%) rename src/garage/{cli_v2 => cli/remote}/mod.rs (100%) rename src/garage/{cli_v2 => cli/remote}/node.rs (99%) rename src/garage/{cli_v2 => cli/remote}/worker.rs (99%) diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs deleted file mode 100644 index 3bbc2b86..00000000 --- a/src/garage/admin/mod.rs +++ /dev/null @@ -1,540 +0,0 @@ -mod block; -mod bucket; -mod key; - -use std::collections::HashMap; -use std::fmt::Write; -use std::future::Future; -use std::sync::Arc; - -use futures::future::FutureExt; - -use serde::{Deserialize, Serialize}; - -use format_table::format_table_to_string; - -use garage_util::background::BackgroundRunner; -use garage_util::data::*; -use garage_util::error::Error as GarageError; - -use garage_table::replication::*; -use garage_table::*; - -use garage_rpc::layout::PARTITION_BITS; -use garage_rpc::*; - -use garage_block::manager::BlockResyncErrorInfo; - -use garage_model::bucket_table::*; -use garage_model::garage::Garage; -use garage_model::helper::error::{Error, OkOrBadRequest}; -use garage_model::key_table::*; -use garage_model::s3::mpu_table::MultipartUpload; -use garage_model::s3::version_table::Version; - -use crate::cli::*; -use crate::repair::online::launch_online_repair; - -pub const ADMIN_RPC_PATH: &str = "garage/admin_rpc.rs/Rpc"; - -#[derive(Debug, Serialize, Deserialize)] -#[allow(clippy::large_enum_variant)] -pub enum AdminRpc { - BucketOperation(BucketOperation), - KeyOperation(KeyOperation), - LaunchRepair(RepairOpt), - Stats(StatsOpt), - Worker(WorkerOperation), - BlockOperation(BlockOperation), - MetaOperation(MetaOperation), - - // Replies - Ok(String), - BucketList(Vec), - BucketInfo { - bucket: Bucket, - relevant_keys: HashMap, - counters: HashMap, - mpu_counters: HashMap, - }, - KeyList(Vec<(String, String)>), - KeyInfo(Key, HashMap), - WorkerList( - HashMap, - WorkerListOpt, - ), - WorkerVars(Vec<(Uuid, String, String)>), - WorkerInfo(usize, garage_util::background::WorkerInfo), - BlockErrorList(Vec), - BlockInfo { - hash: Hash, - refcount: u64, - versions: Vec>, - uploads: Vec, - }, -} - -impl Rpc for AdminRpc { - type Response = Result; -} - -pub struct AdminRpcHandler { - garage: Arc, - background: Arc, - endpoint: Arc>, -} - -impl AdminRpcHandler { - pub fn new(garage: Arc, background: Arc) -> Arc { - let endpoint = garage.system.netapp.endpoint(ADMIN_RPC_PATH.into()); - let admin = Arc::new(Self { - garage, - background, - endpoint, - }); - admin.endpoint.set_handler(admin.clone()); - admin - } - - // ================ REPAIR COMMANDS ==================== - - async fn handle_launch_repair(self: &Arc, opt: RepairOpt) -> Result { - if !opt.yes { - return Err(Error::BadRequest( - "Please provide the --yes flag to initiate repair operations.".to_string(), - )); - } - if opt.all_nodes { - let mut opt_to_send = opt.clone(); - opt_to_send.all_nodes = false; - - let mut failures = vec![]; - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in all_nodes.iter() { - let node = (*node).into(); - let resp = self - .endpoint - .call( - &node, - AdminRpc::LaunchRepair(opt_to_send.clone()), - PRIO_NORMAL, - ) - .await; - if !matches!(resp, Ok(Ok(_))) { - failures.push(node); - } - } - if failures.is_empty() { - Ok(AdminRpc::Ok("Repair launched on all nodes".to_string())) - } else { - Err(Error::BadRequest(format!( - "Could not launch repair on nodes: {:?} (launched successfully on other nodes)", - failures - ))) - } - } else { - launch_online_repair(&self.garage, &self.background, opt).await?; - Ok(AdminRpc::Ok(format!( - "Repair launched on {:?}", - self.garage.system.id - ))) - } - } - - // ================ STATS COMMANDS ==================== - - async fn handle_stats(&self, opt: StatsOpt) -> Result { - if opt.all_nodes { - let mut ret = String::new(); - let mut all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in self.garage.system.get_known_nodes().iter() { - if node.is_up && !all_nodes.contains(&node.id) { - all_nodes.push(node.id); - } - } - - for node in all_nodes.iter() { - let mut opt = opt.clone(); - opt.all_nodes = false; - opt.skip_global = true; - - writeln!(&mut ret, "\n======================").unwrap(); - writeln!(&mut ret, "Stats for node {:?}:", node).unwrap(); - - let node_id = (*node).into(); - match self - .endpoint - .call(&node_id, AdminRpc::Stats(opt), PRIO_NORMAL) - .await - { - Ok(Ok(AdminRpc::Ok(s))) => writeln!(&mut ret, "{}", s).unwrap(), - Ok(Ok(x)) => writeln!(&mut ret, "Bad answer: {:?}", x).unwrap(), - Ok(Err(e)) => writeln!(&mut ret, "Remote error: {}", e).unwrap(), - Err(e) => writeln!(&mut ret, "Network error: {}", e).unwrap(), - } - } - - writeln!(&mut ret, "\n======================").unwrap(); - write!( - &mut ret, - "Cluster statistics:\n\n{}", - self.gather_cluster_stats() - ) - .unwrap(); - - Ok(AdminRpc::Ok(ret)) - } else { - Ok(AdminRpc::Ok(self.gather_stats_local(opt)?)) - } - } - - fn gather_stats_local(&self, opt: StatsOpt) -> Result { - let mut ret = String::new(); - writeln!( - &mut ret, - "\nGarage version: {} [features: {}]\nRust compiler version: {}", - garage_util::version::garage_version(), - garage_util::version::garage_features() - .map(|list| list.join(", ")) - .unwrap_or_else(|| "(unknown)".into()), - garage_util::version::rust_version(), - ) - .unwrap(); - - writeln!(&mut ret, "\nDatabase engine: {}", self.garage.db.engine()).unwrap(); - - // Gather table statistics - let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tGcTodo".into()]; - table.push(self.gather_table_stats(&self.garage.bucket_table)?); - table.push(self.gather_table_stats(&self.garage.key_table)?); - table.push(self.gather_table_stats(&self.garage.object_table)?); - table.push(self.gather_table_stats(&self.garage.version_table)?); - table.push(self.gather_table_stats(&self.garage.block_ref_table)?); - write!( - &mut ret, - "\nTable stats:\n{}", - format_table_to_string(table) - ) - .unwrap(); - - // Gather block manager statistics - writeln!(&mut ret, "\nBlock manager stats:").unwrap(); - let rc_len = self.garage.block_manager.rc_len()?.to_string(); - - writeln!( - &mut ret, - " number of RC entries (~= number of blocks): {}", - rc_len - ) - .unwrap(); - writeln!( - &mut ret, - " resync queue length: {}", - self.garage.block_manager.resync.queue_len()? - ) - .unwrap(); - writeln!( - &mut ret, - " blocks with resync errors: {}", - self.garage.block_manager.resync.errors_len()? - ) - .unwrap(); - - if !opt.skip_global { - write!(&mut ret, "\n{}", self.gather_cluster_stats()).unwrap(); - } - - Ok(ret) - } - - fn gather_cluster_stats(&self) -> String { - let mut ret = String::new(); - - // Gather storage node and free space statistics for current nodes - let layout = &self.garage.system.cluster_layout(); - let mut node_partition_count = HashMap::::new(); - for short_id in layout.current().ring_assignment_data.iter() { - let id = layout.current().node_id_vec[*short_id as usize]; - *node_partition_count.entry(id).or_default() += 1; - } - let node_info = self - .garage - .system - .get_known_nodes() - .into_iter() - .map(|n| (n.id, n)) - .collect::>(); - - let mut table = vec![" ID\tHostname\tZone\tCapacity\tPart.\tDataAvail\tMetaAvail".into()]; - for (id, parts) in node_partition_count.iter() { - let info = node_info.get(id); - let status = info.map(|x| &x.status); - let role = layout.current().roles.get(id).and_then(|x| x.0.as_ref()); - let hostname = status.and_then(|x| x.hostname.as_deref()).unwrap_or("?"); - let zone = role.map(|x| x.zone.as_str()).unwrap_or("?"); - let capacity = role - .map(|x| x.capacity_string()) - .unwrap_or_else(|| "?".into()); - let avail_str = |x| match x { - Some((avail, total)) => { - let pct = (avail as f64) / (total as f64) * 100.; - let avail = bytesize::ByteSize::b(avail); - let total = bytesize::ByteSize::b(total); - format!("{}/{} ({:.1}%)", avail, total, pct) - } - None => "?".into(), - }; - let data_avail = avail_str(status.and_then(|x| x.data_disk_avail)); - let meta_avail = avail_str(status.and_then(|x| x.meta_disk_avail)); - table.push(format!( - " {:?}\t{}\t{}\t{}\t{}\t{}\t{}", - id, hostname, zone, capacity, parts, data_avail, meta_avail - )); - } - write!( - &mut ret, - "Storage nodes:\n{}", - format_table_to_string(table) - ) - .unwrap(); - - let meta_part_avail = node_partition_count - .iter() - .filter_map(|(id, parts)| { - node_info - .get(id) - .and_then(|x| x.status.meta_disk_avail) - .map(|c| c.0 / *parts) - }) - .collect::>(); - let data_part_avail = node_partition_count - .iter() - .filter_map(|(id, parts)| { - node_info - .get(id) - .and_then(|x| x.status.data_disk_avail) - .map(|c| c.0 / *parts) - }) - .collect::>(); - if !meta_part_avail.is_empty() && !data_part_avail.is_empty() { - let meta_avail = - bytesize::ByteSize(meta_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); - let data_avail = - bytesize::ByteSize(data_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); - writeln!( - &mut ret, - "\nEstimated available storage space cluster-wide (might be lower in practice):" - ) - .unwrap(); - if meta_part_avail.len() < node_partition_count.len() - || data_part_avail.len() < node_partition_count.len() - { - writeln!(&mut ret, " data: < {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: < {}", meta_avail).unwrap(); - writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap(); - } else { - writeln!(&mut ret, " data: {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: {}", meta_avail).unwrap(); - } - } - - ret - } - - fn gather_table_stats(&self, t: &Arc>) -> Result - where - F: TableSchema + 'static, - R: TableReplication + 'static, - { - let data_len = t.data.store.len().map_err(GarageError::from)?.to_string(); - let mkl_len = t.merkle_updater.merkle_tree_len()?.to_string(); - - Ok(format!( - " {}\t{}\t{}\t{}\t{}", - F::TABLE_NAME, - data_len, - mkl_len, - t.merkle_updater.todo_len()?, - t.data.gc_todo_len()? - )) - } - - // ================ WORKER COMMANDS ==================== - - async fn handle_worker_cmd(&self, cmd: &WorkerOperation) -> Result { - match cmd { - WorkerOperation::List { opt } => { - let workers = self.background.get_worker_info(); - Ok(AdminRpc::WorkerList(workers, *opt)) - } - WorkerOperation::Info { tid } => { - let info = self - .background - .get_worker_info() - .get(tid) - .ok_or_bad_request(format!("No worker with TID {}", tid))? - .clone(); - Ok(AdminRpc::WorkerInfo(*tid, info)) - } - WorkerOperation::Get { - all_nodes, - variable, - } => self.handle_get_var(*all_nodes, variable).await, - WorkerOperation::Set { - all_nodes, - variable, - value, - } => self.handle_set_var(*all_nodes, variable, value).await, - } - } - - async fn handle_get_var( - &self, - all_nodes: bool, - variable: &Option, - ) -> Result { - if all_nodes { - let mut ret = vec![]; - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in all_nodes.iter() { - let node = (*node).into(); - match self - .endpoint - .call( - &node, - AdminRpc::Worker(WorkerOperation::Get { - all_nodes: false, - variable: variable.clone(), - }), - PRIO_NORMAL, - ) - .await?? - { - AdminRpc::WorkerVars(v) => ret.extend(v), - m => return Err(GarageError::unexpected_rpc_message(m).into()), - } - } - Ok(AdminRpc::WorkerVars(ret)) - } else { - #[allow(clippy::collapsible_else_if)] - if let Some(v) = variable { - Ok(AdminRpc::WorkerVars(vec![( - self.garage.system.id, - v.clone(), - self.garage.bg_vars.get(v)?, - )])) - } else { - let mut vars = self.garage.bg_vars.get_all(); - vars.sort(); - Ok(AdminRpc::WorkerVars( - vars.into_iter() - .map(|(k, v)| (self.garage.system.id, k.to_string(), v)) - .collect(), - )) - } - } - } - - async fn handle_set_var( - &self, - all_nodes: bool, - variable: &str, - value: &str, - ) -> Result { - if all_nodes { - let mut ret = vec![]; - let all_nodes = self.garage.system.cluster_layout().all_nodes().to_vec(); - for node in all_nodes.iter() { - let node = (*node).into(); - match self - .endpoint - .call( - &node, - AdminRpc::Worker(WorkerOperation::Set { - all_nodes: false, - variable: variable.to_string(), - value: value.to_string(), - }), - PRIO_NORMAL, - ) - .await?? - { - AdminRpc::WorkerVars(v) => ret.extend(v), - m => return Err(GarageError::unexpected_rpc_message(m).into()), - } - } - Ok(AdminRpc::WorkerVars(ret)) - } else { - self.garage.bg_vars.set(variable, value)?; - Ok(AdminRpc::WorkerVars(vec![( - self.garage.system.id, - variable.to_string(), - value.to_string(), - )])) - } - } - - // ================ META DB COMMANDS ==================== - - async fn handle_meta_cmd(self: &Arc, mo: &MetaOperation) -> Result { - match mo { - MetaOperation::Snapshot { all: true } => { - let to = self.garage.system.cluster_layout().all_nodes().to_vec(); - - let resps = futures::future::join_all(to.iter().map(|to| async move { - let to = (*to).into(); - self.endpoint - .call( - &to, - AdminRpc::MetaOperation(MetaOperation::Snapshot { all: false }), - PRIO_NORMAL, - ) - .await? - })) - .await; - - let mut ret = vec![]; - for (to, resp) in to.iter().zip(resps.iter()) { - let res_str = match resp { - Ok(_) => "ok".to_string(), - Err(e) => format!("error: {}", e), - }; - ret.push(format!("{:?}\t{}", to, res_str)); - } - - if resps.iter().any(Result::is_err) { - Err(GarageError::Message(format_table_to_string(ret)).into()) - } else { - Ok(AdminRpc::Ok(format_table_to_string(ret))) - } - } - MetaOperation::Snapshot { all: false } => { - garage_model::snapshot::async_snapshot_metadata(&self.garage).await?; - Ok(AdminRpc::Ok("Snapshot has been saved.".into())) - } - } - } -} - -impl EndpointHandler for AdminRpcHandler { - fn handle( - self: &Arc, - message: &AdminRpc, - _from: NodeID, - ) -> impl Future> + Send { - let self2 = self.clone(); - async move { - match message { - AdminRpc::BucketOperation(bo) => self2.handle_bucket_cmd(bo).await, - AdminRpc::KeyOperation(ko) => self2.handle_key_cmd(ko).await, - AdminRpc::LaunchRepair(opt) => self2.handle_launch_repair(opt.clone()).await, - AdminRpc::Stats(opt) => self2.handle_stats(opt.clone()).await, - AdminRpc::Worker(wo) => self2.handle_worker_cmd(wo).await, - AdminRpc::BlockOperation(bo) => self2.handle_block_cmd(bo).await, - AdminRpc::MetaOperation(mo) => self2.handle_meta_cmd(mo).await, - m => Err(GarageError::unexpected_rpc_message(m).into()), - } - } - .boxed() - } -} diff --git a/src/garage/cli/convert_db.rs b/src/garage/cli/local/convert_db.rs similarity index 100% rename from src/garage/cli/convert_db.rs rename to src/garage/cli/local/convert_db.rs diff --git a/src/garage/cli/init.rs b/src/garage/cli/local/init.rs similarity index 100% rename from src/garage/cli/init.rs rename to src/garage/cli/local/init.rs diff --git a/src/garage/cli/local/mod.rs b/src/garage/cli/local/mod.rs new file mode 100644 index 00000000..476010b8 --- /dev/null +++ b/src/garage/cli/local/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod convert_db; +pub(crate) mod init; +pub(crate) mod repair; diff --git a/src/garage/cli/repair.rs b/src/garage/cli/local/repair.rs similarity index 100% rename from src/garage/cli/repair.rs rename to src/garage/cli/local/repair.rs diff --git a/src/garage/cli/mod.rs b/src/garage/cli/mod.rs index 146fac56..60e9a5de 100644 --- a/src/garage/cli/mod.rs +++ b/src/garage/cli/mod.rs @@ -1,5 +1,4 @@ -pub(crate) mod structs; +pub mod structs; -pub(crate) mod convert_db; -pub(crate) mod init; -pub(crate) mod repair; +pub mod local; +pub mod remote; diff --git a/src/garage/cli_v2/block.rs b/src/garage/cli/remote/block.rs similarity index 99% rename from src/garage/cli_v2/block.rs rename to src/garage/cli/remote/block.rs index bfc0db4a..933dcbdb 100644 --- a/src/garage/cli_v2/block.rs +++ b/src/garage/cli/remote/block.rs @@ -5,8 +5,8 @@ use garage_util::error::*; use garage_api_admin::api::*; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::*; impl Cli { pub async fn cmd_block(&self, cmd: BlockOperation) -> Result<(), Error> { diff --git a/src/garage/cli_v2/bucket.rs b/src/garage/cli/remote/bucket.rs similarity index 99% rename from src/garage/cli_v2/bucket.rs rename to src/garage/cli/remote/bucket.rs index c25c2c3e..9adcdbe5 100644 --- a/src/garage/cli_v2/bucket.rs +++ b/src/garage/cli/remote/bucket.rs @@ -5,8 +5,8 @@ use garage_util::error::*; use garage_api_admin::api::*; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::*; impl Cli { pub async fn cmd_bucket(&self, cmd: BucketOperation) -> Result<(), Error> { diff --git a/src/garage/cli_v2/cluster.rs b/src/garage/cli/remote/cluster.rs similarity index 98% rename from src/garage/cli_v2/cluster.rs rename to src/garage/cli/remote/cluster.rs index 6eb65d12..9639df8b 100644 --- a/src/garage/cli_v2/cluster.rs +++ b/src/garage/cli/remote/cluster.rs @@ -4,9 +4,9 @@ use garage_util::error::*; use garage_api_admin::api::*; +use crate::cli::remote::layout::*; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::layout::*; -use crate::cli_v2::*; impl Cli { pub async fn cmd_status(&self) -> Result<(), Error> { diff --git a/src/garage/cli_v2/key.rs b/src/garage/cli/remote/key.rs similarity index 99% rename from src/garage/cli_v2/key.rs rename to src/garage/cli/remote/key.rs index b956906d..67843a83 100644 --- a/src/garage/cli_v2/key.rs +++ b/src/garage/cli/remote/key.rs @@ -4,8 +4,8 @@ use garage_util::error::*; use garage_api_admin::api::*; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::*; impl Cli { pub async fn cmd_key(&self, cmd: KeyOperation) -> Result<(), Error> { diff --git a/src/garage/cli_v2/layout.rs b/src/garage/cli/remote/layout.rs similarity index 99% rename from src/garage/cli_v2/layout.rs rename to src/garage/cli/remote/layout.rs index bab6f28e..cd8f99f4 100644 --- a/src/garage/cli_v2/layout.rs +++ b/src/garage/cli/remote/layout.rs @@ -6,8 +6,8 @@ use garage_util::error::*; use garage_api_admin::api::*; use garage_rpc::layout; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::*; impl Cli { pub async fn layout_command_dispatch(&self, cmd: LayoutOperation) -> Result<(), Error> { diff --git a/src/garage/cli_v2/mod.rs b/src/garage/cli/remote/mod.rs similarity index 100% rename from src/garage/cli_v2/mod.rs rename to src/garage/cli/remote/mod.rs diff --git a/src/garage/cli_v2/node.rs b/src/garage/cli/remote/node.rs similarity index 99% rename from src/garage/cli_v2/node.rs rename to src/garage/cli/remote/node.rs index c5d0cdea..419d6bf7 100644 --- a/src/garage/cli_v2/node.rs +++ b/src/garage/cli/remote/node.rs @@ -4,8 +4,8 @@ use garage_util::error::*; use garage_api_admin::api::*; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::*; impl Cli { pub async fn cmd_meta(&self, cmd: MetaOperation) -> Result<(), Error> { diff --git a/src/garage/cli_v2/worker.rs b/src/garage/cli/remote/worker.rs similarity index 99% rename from src/garage/cli_v2/worker.rs rename to src/garage/cli/remote/worker.rs index 9c248a39..45f0b3cd 100644 --- a/src/garage/cli_v2/worker.rs +++ b/src/garage/cli/remote/worker.rs @@ -4,8 +4,8 @@ use garage_util::error::*; use garage_api_admin::api::*; +use crate::cli::remote::*; use crate::cli::structs::*; -use crate::cli_v2::*; impl Cli { pub async fn cmd_worker(&self, cmd: WorkerOperation) -> Result<(), Error> { diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 58d066b3..0af92c35 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -2,7 +2,7 @@ use structopt::StructOpt; use garage_util::version::garage_version; -use crate::cli::convert_db; +use crate::cli::local::convert_db; #[derive(StructOpt, Debug)] pub enum Command { diff --git a/src/garage/main.rs b/src/garage/main.rs index 5d392c44..a72b860c 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -5,7 +5,6 @@ extern crate tracing; mod cli; -mod cli_v2; mod secrets; mod server; #[cfg(feature = "telemetry-otlp")] @@ -144,13 +143,13 @@ async fn main() { let res = match opt.cmd { Command::Server => server::run_server(opt.config_file, opt.secrets).await, Command::OfflineRepair(repair_opt) => { - cli::repair::offline_repair(opt.config_file, opt.secrets, repair_opt).await + cli::local::repair::offline_repair(opt.config_file, opt.secrets, repair_opt).await } Command::ConvertDb(conv_opt) => { - cli::convert_db::do_conversion(conv_opt).map_err(From::from) + cli::local::convert_db::do_conversion(conv_opt).map_err(From::from) } Command::Node(NodeOperation::NodeId(node_id_opt)) => { - cli::init::node_id_command(opt.config_file, node_id_opt.quiet) + cli::local::init::node_id_command(opt.config_file, node_id_opt.quiet) } Command::AdminApiSchema => { println!( @@ -260,7 +259,7 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { (id, addrs[0], false) } else { let node_id = garage_rpc::system::read_node_id(&config.as_ref().unwrap().metadata_dir) - .err_context(cli::init::READ_KEY_ERROR)?; + .err_context(cli::local::init::READ_KEY_ERROR)?; if let Some(a) = config.as_ref().and_then(|c| c.rpc_public_addr.as_ref()) { use std::net::ToSocketAddrs; let a = a @@ -291,7 +290,7 @@ async fn cli_command(opt: Opt) -> Result<(), Error> { let proxy_rpc_endpoint = netapp.endpoint::(PROXY_RPC_PATH.into()); - let cli = cli_v2::Cli { + let cli = cli::remote::Cli { proxy_rpc_endpoint, rpc_host: id, }; From cef8d75983d301ec0d1e78e9ce2c1c460ffe91e0 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 7 Mar 2025 16:25:55 +0100 Subject: [PATCH 064/192] admin api: avoid overwriting redirect rules in UpdateBucket --- src/api/admin/bucket.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ca5b2d86..966546bb 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -371,13 +371,17 @@ impl RequestHandler for UpdateBucketRequest { if let Some(wa) = self.body.website_access { if wa.enabled { + let (redirect_all, routing_rules) = match state.website_config.get() { + Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()), + None => (None, Vec::new()), + }; state.website_config.update(Some(WebsiteConfig { index_document: wa.index_document.ok_or_bad_request( "Please specify indexDocument when enabling website access.", )?, error_document: wa.error_document, - redirect_all: None, - routing_rules: Vec::new(), + redirect_all, + routing_rules, })); } else { if wa.index_document.is_some() || wa.error_document.is_some() { From 3b49dd9e639a0647268dd74156df69242d7e5ad5 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 09:19:20 +0100 Subject: [PATCH 065/192] admin api: small refactor + add comments to layout-related calls --- doc/api/garage-admin-v2.json | 156 +++++++++++++++++++------------- src/api/admin/api.rs | 106 +++++++++++++++++++--- src/api/admin/cluster.rs | 28 +++--- src/garage/cli/remote/layout.rs | 20 +--- 4 files changed, 203 insertions(+), 107 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 921d8d4c..97de3a71 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1412,6 +1412,7 @@ "version": { "type": "integer", "format": "int64", + "description": "As a safety measure, the new version number of the layout must\nbe specified here", "minimum": 0 } } @@ -1424,13 +1425,15 @@ ], "properties": { "layout": { - "$ref": "#/components/schemas/GetClusterLayoutResponse" + "$ref": "#/components/schemas/GetClusterLayoutResponse", + "description": "Details about the new cluster layout" }, "message": { "type": "array", "items": { "type": "string" - } + }, + "description": "Plain-text information about the layout computation\n(do not try to parse this)" } } }, @@ -1666,11 +1669,13 @@ ], "properties": { "allowMissingData": { - "type": "boolean" + "type": "boolean", + "description": "Allow the skip even if a quorum of nodes could not be found for\nthe data among the remaining nodes" }, "version": { "type": "integer", "format": "int64", + "description": "Version number of the layout to assume is currently up-to-date.\nThis will generally be the current layout version.", "minimum": 0 } } @@ -1686,13 +1691,15 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Nodes for which the ACK update tracker has been updated to `version`" }, "syncUpdated": { "type": "array", "items": { "type": "string" - } + }, + "description": "If `allow_missing_data` is set,\nnodes for which the SYNC update tracker has been updated to `version`" } } }, @@ -1708,19 +1715,23 @@ "gatewayNodes": { "type": "integer", "format": "int64", + "description": "Number of nodes with a gateway role in this layout version", "minimum": 0 }, "status": { - "$ref": "#/components/schemas/ClusterLayoutVersionStatus" + "$ref": "#/components/schemas/ClusterLayoutVersionStatus", + "description": "Status of this layout version" }, "storageNodes": { "type": "integer", "format": "int64", + "description": "Number of nodes with an assigned storage capacity in this layout version", "minimum": 0 }, "version": { "type": "integer", "format": "int64", + "description": "Version number of this layout version", "minimum": 0 } } @@ -1836,11 +1847,13 @@ "available": { "type": "integer", "format": "int64", + "description": "Number of bytes available", "minimum": 0 }, "total": { "type": "integer", "format": "int64", + "description": "Total number of bytes", "minimum": 0 } } @@ -2038,11 +2051,13 @@ "currentVersion": { "type": "integer", "format": "int64", + "description": "The current version number of the cluster layout", "minimum": 0 }, "minAck": { "type": "integer", "format": "int64", + "description": "All nodes in the cluster are aware of layout versions up to\nthis version number (at least)", "minimum": 0 }, "updateTrackers": { @@ -2050,6 +2065,7 @@ "object", "null" ], + "description": "Detailed update trackers for nodes (see\n`https://garagehq.deuxfleurs.fr/blog/2023-12-preserving-read-after-write-consistency/`)", "additionalProperties": { "$ref": "#/components/schemas/NodeUpdateTrackers" }, @@ -2061,7 +2077,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/ClusterLayoutVersion" - } + }, + "description": "Layout version history" } } }, @@ -2070,24 +2087,27 @@ "required": [ "version", "roles", - "partitionSize", "parameters", + "partitionSize", "stagedRoleChanges" ], "properties": { "parameters": { - "$ref": "#/components/schemas/LayoutParameters" + "$ref": "#/components/schemas/LayoutParameters", + "description": "Layout parameters used when the current layout was computed" }, "partitionSize": { "type": "integer", "format": "int64", + "description": "The size, in bytes, of one Garage partition (= a shard)", "minimum": 0 }, "roles": { "type": "array", "items": { "$ref": "#/components/schemas/LayoutNodeRole" - } + }, + "description": "List of nodes that currently have a role in the cluster layout" }, "stagedParameters": { "oneOf": [ @@ -2095,7 +2115,8 @@ "type": "null" }, { - "$ref": "#/components/schemas/LayoutParameters" + "$ref": "#/components/schemas/LayoutParameters", + "description": "Layout parameters to use when computing the next version of\nthe cluster layout" } ] }, @@ -2103,11 +2124,13 @@ "type": "array", "items": { "$ref": "#/components/schemas/NodeRoleChange" - } + }, + "description": "List of nodes that will have a new role or whose role will be\nremoved in the next version of the cluster layout" }, "version": { "type": "integer", "format": "int64", + "description": "The current version number of the cluster layout", "minimum": 0 } } @@ -2133,13 +2156,15 @@ "layoutVersion": { "type": "integer", "format": "int64", + "description": "Current version number of the cluster layout", "minimum": 0 }, "nodes": { "type": "array", "items": { "$ref": "#/components/schemas/NodeResp" - } + }, + "description": "List of nodes that are either currently connected, part of the\ncurrent cluster layout, or part of an older cluster layout that\nis still active in the cluster (being drained)." } } }, @@ -2250,16 +2275,28 @@ "null" ], "format": "int64", + "description": "Capacity (in bytes) assigned by the cluster administrator,\nabsent for gateway nodes", "minimum": 0 }, "id": { - "type": "string" + "type": "string", + "description": "Identifier of the node" + }, + "storedPartitions": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of partitions stored on this node\n(a result of the layout computation)", + "minimum": 0 }, "tags": { "type": "array", "items": { "type": "string" - } + }, + "description": "List of tags assigned by the cluster administrator" }, "usableCapacity": { "type": [ @@ -2267,10 +2304,12 @@ "null" ], "format": "int64", + "description": "Capacity (in bytes) that is actually usable on this node in the current\nlayout, which is equal to `stored_partitions` × `partition_size`", "minimum": 0 }, "zone": { - "type": "string" + "type": "string", + "description": "Zone name assigned by the cluster administrator" } } }, @@ -2281,7 +2320,8 @@ ], "properties": { "zoneRedundancy": { - "$ref": "#/components/schemas/ZoneRedundancy" + "$ref": "#/components/schemas/ZoneRedundancy", + "description": "Minimum number of zones in which a data partition must be replicated" } } }, @@ -3071,7 +3111,6 @@ "NodeAssignedRole": { "type": "object", "required": [ - "id", "zone", "tags" ], @@ -3082,19 +3121,19 @@ "null" ], "format": "int64", + "description": "Capacity (in bytes) assigned by the cluster administrator,\nabsent for gateway nodes", "minimum": 0 }, - "id": { - "type": "string" - }, "tags": { "type": "array", "items": { "type": "string" - } + }, + "description": "List of tags assigned by the cluster administrator" }, "zone": { - "type": "string" + "type": "string", + "description": "Zone name assigned by the cluster administrator" } } }, @@ -3110,7 +3149,8 @@ "type": [ "string", "null" - ] + ], + "description": "Socket address used by other nodes to connect to this node for RPC" }, "dataPartition": { "oneOf": [ @@ -3118,24 +3158,29 @@ "type": "null" }, { - "$ref": "#/components/schemas/FreeSpaceResp" + "$ref": "#/components/schemas/FreeSpaceResp", + "description": "Total and available space on the disk partition(s) containing the data\ndirectory(ies)" } ] }, "draining": { - "type": "boolean" + "type": "boolean", + "description": "Whether this node is part of an older layout version and is draining data." }, "hostname": { "type": [ "string", "null" - ] + ], + "description": "Hostname of the node" }, "id": { - "type": "string" + "type": "string", + "description": "Full-length node identifier" }, "isUp": { - "type": "boolean" + "type": "boolean", + "description": "Whether this node is connected in the cluster" }, "lastSeenSecsAgo": { "type": [ @@ -3143,6 +3188,7 @@ "null" ], "format": "int64", + "description": "For disconnected nodes, the number of seconds since last contact,\nor `null` if no contact was established since Garage restarted.", "minimum": 0 }, "metadataPartition": { @@ -3151,7 +3197,8 @@ "type": "null" }, { - "$ref": "#/components/schemas/FreeSpaceResp" + "$ref": "#/components/schemas/FreeSpaceResp", + "description": "Total and available space on the disk partition containing the\nmetadata directory" } ] }, @@ -3161,7 +3208,8 @@ "type": "null" }, { - "$ref": "#/components/schemas/NodeAssignedRole" + "$ref": "#/components/schemas/NodeAssignedRole", + "description": "Role assigned to this node in the current cluster layout" } ] } @@ -3201,33 +3249,7 @@ } }, { - "type": "object", - "required": [ - "zone", - "tags" - ], - "properties": { - "capacity": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "New capacity (in bytes) of the node", - "minimum": 0 - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "New tags of the node" - }, - "zone": { - "type": "string", - "description": "New zone of the node" - } - } + "$ref": "#/components/schemas/NodeAssignedRole" } ] }, @@ -3265,7 +3287,8 @@ ], "properties": { "error": { - "type": "string" + "type": "string", + "description": "Error message indicating that the layout could not be computed\nwith the provided configuration" } } }, @@ -3280,10 +3303,12 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Plain-text information about the layout computation\n(do not try to parse this)" }, "newLayout": { - "$ref": "#/components/schemas/GetClusterLayoutResponse" + "$ref": "#/components/schemas/GetClusterLayoutResponse", + "description": "Details about the new cluster layout" } } } @@ -3439,7 +3464,8 @@ "type": "null" }, { - "$ref": "#/components/schemas/LayoutParameters" + "$ref": "#/components/schemas/LayoutParameters", + "description": "New layout computation parameters to use" } ] }, @@ -3447,7 +3473,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/NodeRoleChange" - } + }, + "description": "New node roles to assign or remove in the cluster layout" } } }, @@ -3631,18 +3658,21 @@ "oneOf": [ { "type": "object", + "description": "Partitions must be replicated in at least this number of\ndistinct zones.", "required": [ "atLeast" ], "properties": { "atLeast": { "type": "integer", + "description": "Partitions must be replicated in at least this number of\ndistinct zones.", "minimum": 0 } } }, { "type": "string", + "description": "Partitions must be replicated in as many zones as possible:\nas many zones as there are replicas, if there are enough distinct\nzones, or at least one in each zone otherwise.", "enum": [ "maximum" ] diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index ec0a9e3c..78706ce3 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -168,23 +168,39 @@ pub struct GetClusterStatusRequest; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterStatusResponse { + /// Current version number of the cluster layout pub layout_version: u64, + /// List of nodes that are either currently connected, part of the + /// current cluster layout, or part of an older cluster layout that + /// is still active in the cluster (being drained). pub nodes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeResp { + /// Full-length node identifier pub id: String, + /// Role assigned to this node in the current cluster layout pub role: Option, - #[schema(value_type = Option )] + /// Socket address used by other nodes to connect to this node for RPC + #[schema(value_type = Option)] pub addr: Option, + /// Hostname of the node pub hostname: Option, + /// Whether this node is connected in the cluster pub is_up: bool, + /// For disconnected nodes, the number of seconds since last contact, + /// or `null` if no contact was established since Garage restarted. pub last_seen_secs_ago: Option, + /// Whether this node is part of an older layout version and is draining data. pub draining: bool, + /// Total and available space on the disk partition(s) containing the data + /// directory(ies) #[serde(default, skip_serializing_if = "Option::is_none")] pub data_partition: Option, + /// Total and available space on the disk partition containing the + /// metadata directory #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata_partition: Option, } @@ -192,16 +208,21 @@ pub struct NodeResp { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct NodeAssignedRole { - pub id: String, + /// Zone name assigned by the cluster administrator pub zone: String, - pub capacity: Option, + /// List of tags assigned by the cluster administrator pub tags: Vec, + /// Capacity (in bytes) assigned by the cluster administrator, + /// absent for gateway nodes + pub capacity: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct FreeSpaceResp { + /// Number of bytes available pub available: u64, + /// Total number of bytes pub total: u64, } @@ -273,22 +294,40 @@ pub struct GetClusterLayoutRequest; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterLayoutResponse { + /// The current version number of the cluster layout pub version: u64, + /// List of nodes that currently have a role in the cluster layout pub roles: Vec, - pub partition_size: u64, + /// Layout parameters used when the current layout was computed pub parameters: LayoutParameters, + /// The size, in bytes, of one Garage partition (= a shard) + pub partition_size: u64, + /// List of nodes that will have a new role or whose role will be + /// removed in the next version of the cluster layout pub staged_role_changes: Vec, + /// Layout parameters to use when computing the next version of + /// the cluster layout pub staged_parameters: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LayoutNodeRole { + /// Identifier of the node pub id: String, + /// Zone name assigned by the cluster administrator pub zone: String, - pub capacity: Option, - pub usable_capacity: Option, + /// List of tags assigned by the cluster administrator pub tags: Vec, + /// Capacity (in bytes) assigned by the cluster administrator, + /// absent for gateway nodes + pub capacity: Option, + /// Number of partitions stored on this node + /// (a result of the layout computation) + pub stored_partitions: Option, + /// Capacity (in bytes) that is actually usable on this node in the current + /// layout, which is equal to `stored_partitions` × `partition_size` + pub usable_capacity: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -309,26 +348,25 @@ pub enum NodeRoleChangeEnum { remove: bool, }, #[serde(rename_all = "camelCase")] - Update { - /// New zone of the node - zone: String, - /// New capacity (in bytes) of the node - capacity: Option, - /// New tags of the node - tags: Vec, - }, + Update(NodeAssignedRole), } #[derive(Copy, Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LayoutParameters { + /// Minimum number of zones in which a data partition must be replicated pub zone_redundancy: ZoneRedundancy, } #[derive(Copy, Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub enum ZoneRedundancy { + /// Partitions must be replicated in at least this number of + /// distinct zones. AtLeast(usize), + /// Partitions must be replicated in as many zones as possible: + /// as many zones as there are replicas, if there are enough distinct + /// zones, or at least one in each zone otherwise. Maximum, } @@ -340,25 +378,42 @@ pub struct GetClusterLayoutHistoryRequest; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetClusterLayoutHistoryResponse { + /// The current version number of the cluster layout pub current_version: u64, + /// All nodes in the cluster are aware of layout versions up to + /// this version number (at least) pub min_ack: u64, + /// Layout version history pub versions: Vec, + /// Detailed update trackers for nodes (see + /// `https://garagehq.deuxfleurs.fr/blog/2023-12-preserving-read-after-write-consistency/`) pub update_trackers: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ClusterLayoutVersion { + /// Version number of this layout version pub version: u64, + /// Status of this layout version pub status: ClusterLayoutVersionStatus, + /// Number of nodes with an assigned storage capacity in this layout version pub storage_nodes: u64, + /// Number of nodes with a gateway role in this layout version pub gateway_nodes: u64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum ClusterLayoutVersionStatus { + /// This is the most up-to-date layout version Current, + /// This version is still active in the cluster because metadata + /// is being rebalanced or migrated from old nodes Draining, + /// This version is no longer active in the cluster for metadata + /// reads and writes. Note that there is still the possibility + /// that data blocks are being migrated away from nodes in this + /// layout version. Historical, } @@ -374,8 +429,10 @@ pub struct NodeUpdateTrackers { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateClusterLayoutRequest { + /// New node roles to assign or remove in the cluster layout #[serde(default)] pub roles: Vec, + /// New layout computation parameters to use #[serde(default)] pub parameters: Option, } @@ -392,10 +449,17 @@ pub struct PreviewClusterLayoutChangesRequest; #[serde(untagged)] pub enum PreviewClusterLayoutChangesResponse { #[serde(rename_all = "camelCase")] - Error { error: String }, + Error { + /// Error message indicating that the layout could not be computed + /// with the provided configuration + error: String, + }, #[serde(rename_all = "camelCase")] Success { + /// Plain-text information about the layout computation + /// (do not try to parse this) message: Vec, + /// Details about the new cluster layout new_layout: GetClusterLayoutResponse, }, } @@ -405,13 +469,18 @@ pub enum PreviewClusterLayoutChangesResponse { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutRequest { + /// As a safety measure, the new version number of the layout must + /// be specified here pub version: u64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApplyClusterLayoutResponse { + /// Plain-text information about the layout computation + /// (do not try to parse this) pub message: Vec, + /// Details about the new cluster layout pub layout: GetClusterLayoutResponse, } @@ -428,14 +497,21 @@ pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse); #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ClusterLayoutSkipDeadNodesRequest { + /// Version number of the layout to assume is currently up-to-date. + /// This will generally be the current layout version. pub version: u64, + /// Allow the skip even if a quorum of nodes could not be found for + /// the data among the remaining nodes pub allow_missing_data: bool, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ClusterLayoutSkipDeadNodesResponse { + /// Nodes for which the ACK update tracker has been updated to `version` pub ack_updated: Vec, + /// If `allow_missing_data` is set, + /// nodes for which the SYNC update tracker has been updated to `version` pub sync_updated: Vec, } diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 8171aa98..c86b3237 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -56,7 +56,6 @@ impl RequestHandler for GetClusterStatusRequest { for (id, _, role) in layout.current().roles.items().iter() { if let layout::NodeRoleV(Some(r)) = role { let role = NodeAssignedRole { - id: hex::encode(id), zone: r.zone.to_string(), capacity: r.capacity, tags: r.tags.clone(), @@ -189,15 +188,16 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp .items() .iter() .filter_map(|(k, _, v)| v.0.clone().map(|x| (k, x))) - .map(|(k, v)| LayoutNodeRole { - id: hex::encode(k), - zone: v.zone.clone(), - capacity: v.capacity, - usable_capacity: current - .get_node_usage(k) - .ok() - .map(|x| x as u64 * current.partition_size), - tags: v.tags.clone(), + .map(|(k, v)| { + let stored_partitions = current.get_node_usage(k).ok().map(|x| x as u64); + LayoutNodeRole { + id: hex::encode(k), + zone: v.zone.clone(), + capacity: v.capacity, + stored_partitions, + usable_capacity: stored_partitions.map(|x| x * current.partition_size), + tags: v.tags.clone(), + } }) .collect::>(); @@ -215,11 +215,11 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp }, Some(r) => NodeRoleChange { id: hex::encode(k), - action: NodeRoleChangeEnum::Update { + action: NodeRoleChangeEnum::Update(NodeAssignedRole { zone: r.zone.clone(), capacity: r.capacity, tags: r.tags.clone(), - }, + }), }, }) .collect::>(); @@ -346,11 +346,11 @@ impl RequestHandler for UpdateClusterLayoutRequest { let new_role = match change.action { NodeRoleChangeEnum::Remove { remove: true } => None, - NodeRoleChangeEnum::Update { + NodeRoleChangeEnum::Update(NodeAssignedRole { zone, capacity, tags, - } => { + }) => { if matches!(capacity, Some(cap) if cap < 1024) { return Err(Error::bad_request("Capacity should be at least 1K (1024)")); } diff --git a/src/garage/cli/remote/layout.rs b/src/garage/cli/remote/layout.rs index cd8f99f4..201dbcf7 100644 --- a/src/garage/cli/remote/layout.rs +++ b/src/garage/cli/remote/layout.rs @@ -120,11 +120,11 @@ impl Cli { actions.push(NodeRoleChange { id, - action: NodeRoleChangeEnum::Update { + action: NodeRoleChangeEnum::Update(NodeAssignedRole { zone, capacity, tags, - }, + }), }); } @@ -340,16 +340,7 @@ pub fn get_staged_or_current_role( if node.id == id { return match &node.action { NodeRoleChangeEnum::Remove { .. } => None, - NodeRoleChangeEnum::Update { - zone, - capacity, - tags, - } => Some(NodeAssignedRole { - id: id.to_string(), - zone: zone.to_string(), - capacity: *capacity, - tags: tags.clone(), - }), + NodeRoleChangeEnum::Update(role) => Some(role.clone()), }; } } @@ -357,7 +348,6 @@ pub fn get_staged_or_current_role( for node in layout.roles.iter() { if node.id == id { return Some(NodeAssignedRole { - id: node.id.clone(), zone: node.zone.clone(), capacity: node.capacity, tags: node.tags.clone(), @@ -437,11 +427,11 @@ pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { let mut table = vec!["ID\tTags\tZone\tCapacity".to_string()]; for change in layout.staged_role_changes.iter() { match &change.action { - NodeRoleChangeEnum::Update { + NodeRoleChangeEnum::Update(NodeAssignedRole { tags, zone, capacity, - } => { + }) => { let tags = tags.join(","); table.push(format!( "{:.16}\t{}\t{}\t{}", From e83864af24cc5be706ea5807a5aacd890006724d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 09:29:38 +0100 Subject: [PATCH 066/192] layout: better encapsulation --- src/api/admin/cluster.rs | 9 +++------ src/rpc/layout/mod.rs | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index c86b3237..41049d5e 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -248,8 +248,8 @@ impl RequestHandler for GetClusterLayoutHistoryRequest { garage: &Arc, _admin: &Admin, ) -> Result { - let layout = garage.system.cluster_layout(); - let layout = layout.inner(); + let layout_helper = garage.system.cluster_layout(); + let layout = layout_helper.inner(); let min_stored = layout.min_stored(); let versions = layout @@ -289,10 +289,7 @@ impl RequestHandler for GetClusterLayoutHistoryRequest { .collect::>(); let all_nodes = layout.get_all_nodes(); - let min_ack = layout - .update_trackers - .ack_map - .min_among(&all_nodes, layout.min_stored()); + let min_ack = layout_helper.ack_map_min(); let update_trackers = if layout.versions.len() > 1 { Some( diff --git a/src/rpc/layout/mod.rs b/src/rpc/layout/mod.rs index ce21a524..0d8ed05f 100644 --- a/src/rpc/layout/mod.rs +++ b/src/rpc/layout/mod.rs @@ -455,7 +455,7 @@ impl UpdateTracker { } } - pub fn min_among(&self, storage_nodes: &[Uuid], min_version: u64) -> u64 { + fn min_among(&self, storage_nodes: &[Uuid], min_version: u64) -> u64 { storage_nodes .iter() .map(|x| self.get(x, min_version)) From df758e8e0db76dd5d5608d6b4a8cd3a867238efd Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 09:54:05 +0100 Subject: [PATCH 067/192] cli v2: simplify --- src/garage/cli/remote/layout.rs | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/garage/cli/remote/layout.rs b/src/garage/cli/remote/layout.rs index 201dbcf7..dcd96e12 100644 --- a/src/garage/cli/remote/layout.rs +++ b/src/garage/cli/remote/layout.rs @@ -71,16 +71,10 @@ impl Cli { let status = self.api_request(GetClusterStatusRequest).await?; let layout = self.api_request(GetClusterLayoutRequest).await?; - let all_node_ids_iter = status - .nodes - .iter() - .map(|x| x.id.as_str()) - .chain(layout.roles.iter().map(|x| x.id.as_str())); - let mut actions = vec![]; for node in opt.replace.iter() { - let id = find_matching_node(all_node_ids_iter.clone(), &node)?; + let id = find_matching_node(&status, &layout, &node)?; actions.push(NodeRoleChange { id, @@ -89,7 +83,7 @@ impl Cli { } for node in opt.node_ids.iter() { - let id = find_matching_node(all_node_ids_iter.clone(), &node)?; + let id = find_matching_node(&status, &layout, &node)?; let current = get_staged_or_current_role(&id, &layout); @@ -144,13 +138,7 @@ impl Cli { let status = self.api_request(GetClusterStatusRequest).await?; let layout = self.api_request(GetClusterLayoutRequest).await?; - let all_node_ids_iter = status - .nodes - .iter() - .map(|x| x.id.as_str()) - .chain(layout.roles.iter().map(|x| x.id.as_str())); - - let id = find_matching_node(all_node_ids_iter.clone(), &opt.node_id)?; + let id = find_matching_node(&status, &layout, &opt.node_id)?; let actions = vec![NodeRoleChange { id, @@ -359,11 +347,18 @@ pub fn get_staged_or_current_role( } pub fn find_matching_node<'a>( - cand: impl std::iter::Iterator, + status: &GetClusterStatusResponse, + layout: &GetClusterLayoutResponse, pattern: &'a str, ) -> Result { + let all_node_ids_iter = status + .nodes + .iter() + .map(|x| x.id.as_str()) + .chain(layout.roles.iter().map(|x| x.id.as_str())); + let mut candidates = vec![]; - for c in cand { + for c in all_node_ids_iter { if c.starts_with(pattern) && !candidates.contains(&c) { candidates.push(c); } From 5f308bd688844f1b7987084abaae51e5bb0dd32c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 10:00:37 +0100 Subject: [PATCH 068/192] move zone redundancy parsing/formatting to cli --- src/garage/cli/remote/layout.rs | 37 ++++++++++++++++++++++++--------- src/rpc/layout/mod.rs | 26 ----------------------- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/src/garage/cli/remote/layout.rs b/src/garage/cli/remote/layout.rs index dcd96e12..f350ab66 100644 --- a/src/garage/cli/remote/layout.rs +++ b/src/garage/cli/remote/layout.rs @@ -4,7 +4,6 @@ use format_table::format_table; use garage_util::error::*; use garage_api_admin::api::*; -use garage_rpc::layout; use crate::cli::remote::*; use crate::cli::structs::*; @@ -162,18 +161,17 @@ impl Cli { match config_opt.redundancy { None => (), Some(r_str) => { - let r = r_str - .parse::() - .ok_or_message("invalid zone redundancy value")?; + let r = parse_zone_redundancy(&r_str)?; self.api_request(UpdateClusterLayoutRequest { roles: vec![], - parameters: Some(LayoutParameters { - zone_redundancy: r.into(), - }), + parameters: Some(LayoutParameters { zone_redundancy: r }), }) .await?; - println!("The zone redundancy parameter has been set to '{}'.", r); + println!( + "The zone redundancy parameter has been set to '{}'.", + display_zone_redundancy(r) + ); did_something = true; } } @@ -403,7 +401,7 @@ pub fn print_cluster_layout(layout: &GetClusterLayoutResponse, empty_msg: &str) println!(); println!( "Zone redundancy: {}", - Into::::into(layout.parameters.zone_redundancy) + display_zone_redundancy(layout.parameters.zone_redundancy), ); } else { println!("{}", empty_msg); @@ -447,7 +445,7 @@ pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { if let Some(p) = layout.staged_parameters.as_ref() { println!( "Zone redundancy: {}", - Into::::into(p.zone_redundancy) + display_zone_redundancy(p.zone_redundancy) ); } true @@ -455,3 +453,22 @@ pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { false } } + +pub fn display_zone_redundancy(z: ZoneRedundancy) -> String { + match z { + ZoneRedundancy::Maximum => "maximum".into(), + ZoneRedundancy::AtLeast(x) => x.to_string(), + } +} + +pub fn parse_zone_redundancy(s: &str) -> Result { + match s { + "none" | "max" | "maximum" => Ok(ZoneRedundancy::Maximum), + x => { + let v = x.parse::().map_err(|_| { + Error::Message("zone redundancy must be 'none'/'max' or an integer".into()) + })?; + Ok(ZoneRedundancy::AtLeast(v)) + } + } +} diff --git a/src/rpc/layout/mod.rs b/src/rpc/layout/mod.rs index 0d8ed05f..cfd576a7 100644 --- a/src/rpc/layout/mod.rs +++ b/src/rpc/layout/mod.rs @@ -1,5 +1,3 @@ -use std::fmt; - use bytesize::ByteSize; use garage_util::crdt::{AutoCrdt, Crdt}; @@ -397,30 +395,6 @@ impl NodeRole { } } -impl fmt::Display for ZoneRedundancy { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ZoneRedundancy::Maximum => write!(f, "maximum"), - ZoneRedundancy::AtLeast(x) => write!(f, "{}", x), - } - } -} - -impl core::str::FromStr for ZoneRedundancy { - type Err = &'static str; - fn from_str(s: &str) -> Result { - match s { - "none" | "max" | "maximum" => Ok(ZoneRedundancy::Maximum), - x => { - let v = x - .parse::() - .map_err(|_| "zone redundancy must be 'none'/'max' or an integer")?; - Ok(ZoneRedundancy::AtLeast(v)) - } - } - } -} - impl UpdateTracker { fn merge(&mut self, other: &UpdateTracker) -> bool { let mut changed = false; From 1f645830a4d2e060333fd67045e855793a2f2b8a Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 10:05:02 +0100 Subject: [PATCH 069/192] layout: make optional version mandatory in apply layout changes --- src/api/admin/cluster.rs | 4 ++-- src/rpc/layout/history.rs | 17 +++-------------- src/rpc/layout/test.rs | 8 ++++---- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 41049d5e..f41766b9 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -404,7 +404,7 @@ impl RequestHandler for PreviewClusterLayoutChangesRequest { ) -> Result { let layout = garage.system.cluster_layout().inner().clone(); let new_ver = layout.current().version + 1; - match layout.apply_staged_changes(Some(new_ver)) { + match layout.apply_staged_changes(new_ver) { Err(GarageError::Message(error)) => { Ok(PreviewClusterLayoutChangesResponse::Error { error }) } @@ -426,7 +426,7 @@ impl RequestHandler for ApplyClusterLayoutRequest { _admin: &Admin, ) -> Result { let layout = garage.system.cluster_layout().inner().clone(); - let (layout, msg) = layout.apply_staged_changes(Some(self.version))?; + let (layout, msg) = layout.apply_staged_changes(self.version)?; garage .system diff --git a/src/rpc/layout/history.rs b/src/rpc/layout/history.rs index 574c50c2..16c32fb2 100644 --- a/src/rpc/layout/history.rs +++ b/src/rpc/layout/history.rs @@ -267,20 +267,9 @@ impl LayoutHistory { changed } - pub fn apply_staged_changes(mut self, version: Option) -> Result<(Self, Message), Error> { - match version { - None => { - let error = r#" -Please pass the new layout version number to ensure that you are writing the correct version of the cluster layout. -To know the correct value of the new layout version, invoke `garage layout show` and review the proposed changes. - "#; - return Err(Error::Message(error.into())); - } - Some(v) => { - if v != self.current().version + 1 { - return Err(Error::Message("Invalid new layout version".into())); - } - } + pub fn apply_staged_changes(mut self, version: u64) -> Result<(Self, Message), Error> { + if version != self.current().version + 1 { + return Err(Error::Message("Invalid new layout version".into())); } // Compute new version and add it to history diff --git a/src/rpc/layout/test.rs b/src/rpc/layout/test.rs index 5462160b..2d29914e 100644 --- a/src/rpc/layout/test.rs +++ b/src/rpc/layout/test.rs @@ -124,7 +124,7 @@ fn test_assignment() { let mut cl = LayoutHistory::new(ReplicationFactor::new(3).unwrap()); update_layout(&mut cl, &node_capacity_vec, &node_zone_vec, 3); let v = cl.current().version; - let (mut cl, msg) = cl.apply_staged_changes(Some(v + 1)).unwrap(); + let (mut cl, msg) = cl.apply_staged_changes(v + 1).unwrap(); show_msg(&msg); assert_eq!(cl.check(), Ok(())); assert!(check_against_naive(cl.current()).unwrap()); @@ -133,7 +133,7 @@ fn test_assignment() { node_zone_vec = vec!["A", "B", "C", "C", "C", "B", "G", "H", "I"]; update_layout(&mut cl, &node_capacity_vec, &node_zone_vec, 2); let v = cl.current().version; - let (mut cl, msg) = cl.apply_staged_changes(Some(v + 1)).unwrap(); + let (mut cl, msg) = cl.apply_staged_changes(v + 1).unwrap(); show_msg(&msg); assert_eq!(cl.check(), Ok(())); assert!(check_against_naive(cl.current()).unwrap()); @@ -141,7 +141,7 @@ fn test_assignment() { node_capacity_vec = vec![4000, 1000, 2000, 7000, 1000, 1000, 2000, 10000, 2000]; update_layout(&mut cl, &node_capacity_vec, &node_zone_vec, 3); let v = cl.current().version; - let (mut cl, msg) = cl.apply_staged_changes(Some(v + 1)).unwrap(); + let (mut cl, msg) = cl.apply_staged_changes(v + 1).unwrap(); show_msg(&msg); assert_eq!(cl.check(), Ok(())); assert!(check_against_naive(cl.current()).unwrap()); @@ -151,7 +151,7 @@ fn test_assignment() { ]; update_layout(&mut cl, &node_capacity_vec, &node_zone_vec, 1); let v = cl.current().version; - let (cl, msg) = cl.apply_staged_changes(Some(v + 1)).unwrap(); + let (cl, msg) = cl.apply_staged_changes(v + 1).unwrap(); show_msg(&msg); assert_eq!(cl.check(), Ok(())); assert!(check_against_naive(cl.current()).unwrap()); From 576d0d950edf4244bcf2abf5956549158bbb03d4 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 10:27:43 +0100 Subject: [PATCH 070/192] admin api: move functions to their correct location --- src/api/admin/cluster.rs | 501 +++++++++------------------------------ src/api/admin/layout.rs | 406 +++++++++++++++++++++++++++++++ src/api/admin/lib.rs | 1 + src/api/admin/node.rs | 106 --------- 4 files changed, 513 insertions(+), 501 deletions(-) create mode 100644 src/api/admin/layout.rs diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index f41766b9..6a555d04 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; +use std::fmt::Write; use std::sync::Arc; -use garage_util::crdt::*; +use format_table::format_table_to_string; + use garage_util::data::*; -use garage_util::error::Error as GarageError; use garage_rpc::layout; +use garage_rpc::layout::PARTITION_BITS; use garage_model::garage::Garage; @@ -140,6 +142,108 @@ impl RequestHandler for GetClusterHealthRequest { } } +impl RequestHandler for GetClusterStatisticsRequest { + type Response = GetClusterStatisticsResponse; + + // FIXME: return this as a JSON struct instead of text + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut ret = String::new(); + + // Gather storage node and free space statistics for current nodes + let layout = &garage.system.cluster_layout(); + let mut node_partition_count = HashMap::::new(); + for short_id in layout.current().ring_assignment_data.iter() { + let id = layout.current().node_id_vec[*short_id as usize]; + *node_partition_count.entry(id).or_default() += 1; + } + let node_info = garage + .system + .get_known_nodes() + .into_iter() + .map(|n| (n.id, n)) + .collect::>(); + + let mut table = vec![" ID\tHostname\tZone\tCapacity\tPart.\tDataAvail\tMetaAvail".into()]; + for (id, parts) in node_partition_count.iter() { + let info = node_info.get(id); + let status = info.map(|x| &x.status); + let role = layout.current().roles.get(id).and_then(|x| x.0.as_ref()); + let hostname = status.and_then(|x| x.hostname.as_deref()).unwrap_or("?"); + let zone = role.map(|x| x.zone.as_str()).unwrap_or("?"); + let capacity = role + .map(|x| x.capacity_string()) + .unwrap_or_else(|| "?".into()); + let avail_str = |x| match x { + Some((avail, total)) => { + let pct = (avail as f64) / (total as f64) * 100.; + let avail = bytesize::ByteSize::b(avail); + let total = bytesize::ByteSize::b(total); + format!("{}/{} ({:.1}%)", avail, total, pct) + } + None => "?".into(), + }; + let data_avail = avail_str(status.and_then(|x| x.data_disk_avail)); + let meta_avail = avail_str(status.and_then(|x| x.meta_disk_avail)); + table.push(format!( + " {:?}\t{}\t{}\t{}\t{}\t{}\t{}", + id, hostname, zone, capacity, parts, data_avail, meta_avail + )); + } + write!( + &mut ret, + "Storage nodes:\n{}", + format_table_to_string(table) + ) + .unwrap(); + + let meta_part_avail = node_partition_count + .iter() + .filter_map(|(id, parts)| { + node_info + .get(id) + .and_then(|x| x.status.meta_disk_avail) + .map(|c| c.0 / *parts) + }) + .collect::>(); + let data_part_avail = node_partition_count + .iter() + .filter_map(|(id, parts)| { + node_info + .get(id) + .and_then(|x| x.status.data_disk_avail) + .map(|c| c.0 / *parts) + }) + .collect::>(); + if !meta_part_avail.is_empty() && !data_part_avail.is_empty() { + let meta_avail = + bytesize::ByteSize(meta_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); + let data_avail = + bytesize::ByteSize(data_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); + writeln!( + &mut ret, + "\nEstimated available storage space cluster-wide (might be lower in practice):" + ) + .unwrap(); + if meta_part_avail.len() < node_partition_count.len() + || data_part_avail.len() < node_partition_count.len() + { + writeln!(&mut ret, " data: < {}", data_avail).unwrap(); + writeln!(&mut ret, " metadata: < {}", meta_avail).unwrap(); + writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap(); + } else { + writeln!(&mut ret, " data: {}", data_avail).unwrap(); + writeln!(&mut ret, " metadata: {}", meta_avail).unwrap(); + } + } + + Ok(GetClusterStatisticsResponse { freeform: ret }) + } +} + impl RequestHandler for ConnectClusterNodesRequest { type Response = ConnectClusterNodesResponse; @@ -165,396 +269,3 @@ impl RequestHandler for ConnectClusterNodesRequest { Ok(ConnectClusterNodesResponse(res)) } } - -impl RequestHandler for GetClusterLayoutRequest { - type Response = GetClusterLayoutResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - Ok(format_cluster_layout( - garage.system.cluster_layout().inner(), - )) - } -} - -fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResponse { - let current = layout.current(); - - let roles = current - .roles - .items() - .iter() - .filter_map(|(k, _, v)| v.0.clone().map(|x| (k, x))) - .map(|(k, v)| { - let stored_partitions = current.get_node_usage(k).ok().map(|x| x as u64); - LayoutNodeRole { - id: hex::encode(k), - zone: v.zone.clone(), - capacity: v.capacity, - stored_partitions, - usable_capacity: stored_partitions.map(|x| x * current.partition_size), - tags: v.tags.clone(), - } - }) - .collect::>(); - - let staged_role_changes = layout - .staging - .get() - .roles - .items() - .iter() - .filter(|(k, _, v)| current.roles.get(k) != Some(v)) - .map(|(k, _, v)| match &v.0 { - None => NodeRoleChange { - id: hex::encode(k), - action: NodeRoleChangeEnum::Remove { remove: true }, - }, - Some(r) => NodeRoleChange { - id: hex::encode(k), - action: NodeRoleChangeEnum::Update(NodeAssignedRole { - zone: r.zone.clone(), - capacity: r.capacity, - tags: r.tags.clone(), - }), - }, - }) - .collect::>(); - - let staged_parameters = if *layout.staging.get().parameters.get() != current.parameters { - Some((*layout.staging.get().parameters.get()).into()) - } else { - None - }; - - GetClusterLayoutResponse { - version: current.version, - roles, - partition_size: current.partition_size, - parameters: current.parameters.into(), - staged_role_changes, - staged_parameters, - } -} - -impl RequestHandler for GetClusterLayoutHistoryRequest { - type Response = GetClusterLayoutHistoryResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let layout_helper = garage.system.cluster_layout(); - let layout = layout_helper.inner(); - let min_stored = layout.min_stored(); - - let versions = layout - .versions - .iter() - .rev() - .chain(layout.old_versions.iter().rev()) - .map(|ver| { - let status = if ver.version == layout.current().version { - ClusterLayoutVersionStatus::Current - } else if ver.version >= min_stored { - ClusterLayoutVersionStatus::Draining - } else { - ClusterLayoutVersionStatus::Historical - }; - ClusterLayoutVersion { - version: ver.version, - status, - storage_nodes: ver - .roles - .items() - .iter() - .filter( - |(_, _, x)| matches!(x, layout::NodeRoleV(Some(c)) if c.capacity.is_some()), - ) - .count() as u64, - gateway_nodes: ver - .roles - .items() - .iter() - .filter( - |(_, _, x)| matches!(x, layout::NodeRoleV(Some(c)) if c.capacity.is_none()), - ) - .count() as u64, - } - }) - .collect::>(); - - let all_nodes = layout.get_all_nodes(); - let min_ack = layout_helper.ack_map_min(); - - let update_trackers = if layout.versions.len() > 1 { - Some( - all_nodes - .iter() - .map(|node| { - ( - hex::encode(&node), - NodeUpdateTrackers { - ack: layout.update_trackers.ack_map.get(node, min_stored), - sync: layout.update_trackers.sync_map.get(node, min_stored), - sync_ack: layout.update_trackers.sync_ack_map.get(node, min_stored), - }, - ) - }) - .collect(), - ) - } else { - None - }; - - Ok(GetClusterLayoutHistoryResponse { - current_version: layout.current().version, - min_ack, - versions, - update_trackers, - }) - } -} - -// ---- - -// ---- update functions ---- - -impl RequestHandler for UpdateClusterLayoutRequest { - type Response = UpdateClusterLayoutResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let mut layout = garage.system.cluster_layout().inner().clone(); - - let mut roles = layout.current().roles.clone(); - roles.merge(&layout.staging.get().roles); - - for change in self.roles { - let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?; - let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?; - - let new_role = match change.action { - NodeRoleChangeEnum::Remove { remove: true } => None, - NodeRoleChangeEnum::Update(NodeAssignedRole { - zone, - capacity, - tags, - }) => { - if matches!(capacity, Some(cap) if cap < 1024) { - return Err(Error::bad_request("Capacity should be at least 1K (1024)")); - } - Some(layout::NodeRole { - zone, - capacity, - tags, - }) - } - _ => return Err(Error::bad_request("Invalid layout change")), - }; - - layout - .staging - .get_mut() - .roles - .merge(&roles.update_mutator(node, layout::NodeRoleV(new_role))); - } - - if let Some(param) = self.parameters { - if let ZoneRedundancy::AtLeast(r_int) = param.zone_redundancy { - if r_int > layout.current().replication_factor { - return Err(Error::bad_request(format!( - "The zone redundancy must be smaller or equal to the replication factor ({}).", - layout.current().replication_factor - ))); - } else if r_int < 1 { - return Err(Error::bad_request( - "The zone redundancy must be at least 1.", - )); - } - } - layout.staging.get_mut().parameters.update(param.into()); - } - - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; - - let res = format_cluster_layout(&layout); - Ok(UpdateClusterLayoutResponse(res)) - } -} - -impl RequestHandler for PreviewClusterLayoutChangesRequest { - type Response = PreviewClusterLayoutChangesResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let layout = garage.system.cluster_layout().inner().clone(); - let new_ver = layout.current().version + 1; - match layout.apply_staged_changes(new_ver) { - Err(GarageError::Message(error)) => { - Ok(PreviewClusterLayoutChangesResponse::Error { error }) - } - Err(e) => Err(e.into()), - Ok((new_layout, msg)) => Ok(PreviewClusterLayoutChangesResponse::Success { - message: msg, - new_layout: format_cluster_layout(&new_layout), - }), - } - } -} - -impl RequestHandler for ApplyClusterLayoutRequest { - type Response = ApplyClusterLayoutResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let layout = garage.system.cluster_layout().inner().clone(); - let (layout, msg) = layout.apply_staged_changes(self.version)?; - - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; - - Ok(ApplyClusterLayoutResponse { - message: msg, - layout: format_cluster_layout(&layout), - }) - } -} - -impl RequestHandler for RevertClusterLayoutRequest { - type Response = RevertClusterLayoutResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let layout = garage.system.cluster_layout().inner().clone(); - let layout = layout.revert_staged_changes()?; - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; - - let res = format_cluster_layout(&layout); - Ok(RevertClusterLayoutResponse(res)) - } -} - -impl RequestHandler for ClusterLayoutSkipDeadNodesRequest { - type Response = ClusterLayoutSkipDeadNodesResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let status = garage.system.get_known_nodes(); - - let mut layout = garage.system.cluster_layout().inner().clone(); - let mut ack_updated = vec![]; - let mut sync_updated = vec![]; - - if layout.versions.len() == 1 { - return Err(Error::bad_request( - "This command cannot be called when there is only one live cluster layout version", - )); - } - - let min_v = layout.min_stored(); - if self.version <= min_v || self.version > layout.current().version { - return Err(Error::bad_request(format!( - "Invalid version, you may use the following version numbers: {}", - (min_v + 1..=layout.current().version) - .map(|x| x.to_string()) - .collect::>() - .join(" ") - ))); - } - - let all_nodes = layout.get_all_nodes(); - for node in all_nodes.iter() { - // Update ACK tracker for dead nodes or for all nodes if --allow-missing-data - if self.allow_missing_data || !status.iter().any(|x| x.id == *node && x.is_up) { - if layout.update_trackers.ack_map.set_max(*node, self.version) { - ack_updated.push(hex::encode(node)); - } - } - - // If --allow-missing-data, update SYNC tracker for all nodes. - if self.allow_missing_data { - if layout.update_trackers.sync_map.set_max(*node, self.version) { - sync_updated.push(hex::encode(node)); - } - } - } - - garage - .system - .layout_manager - .update_cluster_layout(&layout) - .await?; - - Ok(ClusterLayoutSkipDeadNodesResponse { - ack_updated, - sync_updated, - }) - } -} - -// ---- - -impl From for ZoneRedundancy { - fn from(x: layout::ZoneRedundancy) -> Self { - match x { - layout::ZoneRedundancy::Maximum => ZoneRedundancy::Maximum, - layout::ZoneRedundancy::AtLeast(x) => ZoneRedundancy::AtLeast(x), - } - } -} - -impl Into for ZoneRedundancy { - fn into(self) -> layout::ZoneRedundancy { - match self { - ZoneRedundancy::Maximum => layout::ZoneRedundancy::Maximum, - ZoneRedundancy::AtLeast(x) => layout::ZoneRedundancy::AtLeast(x), - } - } -} - -impl From for LayoutParameters { - fn from(x: layout::LayoutParameters) -> Self { - LayoutParameters { - zone_redundancy: x.zone_redundancy.into(), - } - } -} - -impl Into for LayoutParameters { - fn into(self) -> layout::LayoutParameters { - layout::LayoutParameters { - zone_redundancy: self.zone_redundancy.into(), - } - } -} diff --git a/src/api/admin/layout.rs b/src/api/admin/layout.rs new file mode 100644 index 00000000..b0b652e6 --- /dev/null +++ b/src/api/admin/layout.rs @@ -0,0 +1,406 @@ +use std::sync::Arc; + +use garage_util::crdt::*; +use garage_util::data::*; +use garage_util::error::Error as GarageError; + +use garage_rpc::layout; + +use garage_model::garage::Garage; + +use crate::api::*; +use crate::error::*; +use crate::{Admin, RequestHandler}; + +impl RequestHandler for GetClusterLayoutRequest { + type Response = GetClusterLayoutResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + Ok(format_cluster_layout( + garage.system.cluster_layout().inner(), + )) + } +} + +fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResponse { + let current = layout.current(); + + let roles = current + .roles + .items() + .iter() + .filter_map(|(k, _, v)| v.0.clone().map(|x| (k, x))) + .map(|(k, v)| { + let stored_partitions = current.get_node_usage(k).ok().map(|x| x as u64); + LayoutNodeRole { + id: hex::encode(k), + zone: v.zone.clone(), + capacity: v.capacity, + stored_partitions, + usable_capacity: stored_partitions.map(|x| x * current.partition_size), + tags: v.tags.clone(), + } + }) + .collect::>(); + + let staged_role_changes = layout + .staging + .get() + .roles + .items() + .iter() + .filter(|(k, _, v)| current.roles.get(k) != Some(v)) + .map(|(k, _, v)| match &v.0 { + None => NodeRoleChange { + id: hex::encode(k), + action: NodeRoleChangeEnum::Remove { remove: true }, + }, + Some(r) => NodeRoleChange { + id: hex::encode(k), + action: NodeRoleChangeEnum::Update(NodeAssignedRole { + zone: r.zone.clone(), + capacity: r.capacity, + tags: r.tags.clone(), + }), + }, + }) + .collect::>(); + + let staged_parameters = if *layout.staging.get().parameters.get() != current.parameters { + Some((*layout.staging.get().parameters.get()).into()) + } else { + None + }; + + GetClusterLayoutResponse { + version: current.version, + roles, + partition_size: current.partition_size, + parameters: current.parameters.into(), + staged_role_changes, + staged_parameters, + } +} + +impl RequestHandler for GetClusterLayoutHistoryRequest { + type Response = GetClusterLayoutHistoryResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let layout_helper = garage.system.cluster_layout(); + let layout = layout_helper.inner(); + let min_stored = layout.min_stored(); + + let versions = layout + .versions + .iter() + .rev() + .chain(layout.old_versions.iter().rev()) + .map(|ver| { + let status = if ver.version == layout.current().version { + ClusterLayoutVersionStatus::Current + } else if ver.version >= min_stored { + ClusterLayoutVersionStatus::Draining + } else { + ClusterLayoutVersionStatus::Historical + }; + ClusterLayoutVersion { + version: ver.version, + status, + storage_nodes: ver + .roles + .items() + .iter() + .filter( + |(_, _, x)| matches!(x, layout::NodeRoleV(Some(c)) if c.capacity.is_some()), + ) + .count() as u64, + gateway_nodes: ver + .roles + .items() + .iter() + .filter( + |(_, _, x)| matches!(x, layout::NodeRoleV(Some(c)) if c.capacity.is_none()), + ) + .count() as u64, + } + }) + .collect::>(); + + let all_nodes = layout.get_all_nodes(); + let min_ack = layout_helper.ack_map_min(); + + let update_trackers = if layout.versions.len() > 1 { + Some( + all_nodes + .iter() + .map(|node| { + ( + hex::encode(&node), + NodeUpdateTrackers { + ack: layout.update_trackers.ack_map.get(node, min_stored), + sync: layout.update_trackers.sync_map.get(node, min_stored), + sync_ack: layout.update_trackers.sync_ack_map.get(node, min_stored), + }, + ) + }) + .collect(), + ) + } else { + None + }; + + Ok(GetClusterLayoutHistoryResponse { + current_version: layout.current().version, + min_ack, + versions, + update_trackers, + }) + } +} + +// ---- + +// ---- update functions ---- + +impl RequestHandler for UpdateClusterLayoutRequest { + type Response = UpdateClusterLayoutResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut layout = garage.system.cluster_layout().inner().clone(); + + let mut roles = layout.current().roles.clone(); + roles.merge(&layout.staging.get().roles); + + for change in self.roles { + let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?; + let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?; + + let new_role = match change.action { + NodeRoleChangeEnum::Remove { remove: true } => None, + NodeRoleChangeEnum::Update(NodeAssignedRole { + zone, + capacity, + tags, + }) => { + if matches!(capacity, Some(cap) if cap < 1024) { + return Err(Error::bad_request("Capacity should be at least 1K (1024)")); + } + Some(layout::NodeRole { + zone, + capacity, + tags, + }) + } + _ => return Err(Error::bad_request("Invalid layout change")), + }; + + layout + .staging + .get_mut() + .roles + .merge(&roles.update_mutator(node, layout::NodeRoleV(new_role))); + } + + if let Some(param) = self.parameters { + if let ZoneRedundancy::AtLeast(r_int) = param.zone_redundancy { + if r_int > layout.current().replication_factor { + return Err(Error::bad_request(format!( + "The zone redundancy must be smaller or equal to the replication factor ({}).", + layout.current().replication_factor + ))); + } else if r_int < 1 { + return Err(Error::bad_request( + "The zone redundancy must be at least 1.", + )); + } + } + layout.staging.get_mut().parameters.update(param.into()); + } + + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + let res = format_cluster_layout(&layout); + Ok(UpdateClusterLayoutResponse(res)) + } +} + +impl RequestHandler for PreviewClusterLayoutChangesRequest { + type Response = PreviewClusterLayoutChangesResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let layout = garage.system.cluster_layout().inner().clone(); + let new_ver = layout.current().version + 1; + match layout.apply_staged_changes(new_ver) { + Err(GarageError::Message(error)) => { + Ok(PreviewClusterLayoutChangesResponse::Error { error }) + } + Err(e) => Err(e.into()), + Ok((new_layout, msg)) => Ok(PreviewClusterLayoutChangesResponse::Success { + message: msg, + new_layout: format_cluster_layout(&new_layout), + }), + } + } +} + +impl RequestHandler for ApplyClusterLayoutRequest { + type Response = ApplyClusterLayoutResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let layout = garage.system.cluster_layout().inner().clone(); + let (layout, msg) = layout.apply_staged_changes(self.version)?; + + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + Ok(ApplyClusterLayoutResponse { + message: msg, + layout: format_cluster_layout(&layout), + }) + } +} + +impl RequestHandler for RevertClusterLayoutRequest { + type Response = RevertClusterLayoutResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let layout = garage.system.cluster_layout().inner().clone(); + let layout = layout.revert_staged_changes()?; + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + let res = format_cluster_layout(&layout); + Ok(RevertClusterLayoutResponse(res)) + } +} + +impl RequestHandler for ClusterLayoutSkipDeadNodesRequest { + type Response = ClusterLayoutSkipDeadNodesResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let status = garage.system.get_known_nodes(); + + let mut layout = garage.system.cluster_layout().inner().clone(); + let mut ack_updated = vec![]; + let mut sync_updated = vec![]; + + if layout.versions.len() == 1 { + return Err(Error::bad_request( + "This command cannot be called when there is only one live cluster layout version", + )); + } + + let min_v = layout.min_stored(); + if self.version <= min_v || self.version > layout.current().version { + return Err(Error::bad_request(format!( + "Invalid version, you may use the following version numbers: {}", + (min_v + 1..=layout.current().version) + .map(|x| x.to_string()) + .collect::>() + .join(" ") + ))); + } + + let all_nodes = layout.get_all_nodes(); + for node in all_nodes.iter() { + // Update ACK tracker for dead nodes or for all nodes if --allow-missing-data + if self.allow_missing_data || !status.iter().any(|x| x.id == *node && x.is_up) { + if layout.update_trackers.ack_map.set_max(*node, self.version) { + ack_updated.push(hex::encode(node)); + } + } + + // If --allow-missing-data, update SYNC tracker for all nodes. + if self.allow_missing_data { + if layout.update_trackers.sync_map.set_max(*node, self.version) { + sync_updated.push(hex::encode(node)); + } + } + } + + garage + .system + .layout_manager + .update_cluster_layout(&layout) + .await?; + + Ok(ClusterLayoutSkipDeadNodesResponse { + ack_updated, + sync_updated, + }) + } +} + +// ---- + +impl From for ZoneRedundancy { + fn from(x: layout::ZoneRedundancy) -> Self { + match x { + layout::ZoneRedundancy::Maximum => ZoneRedundancy::Maximum, + layout::ZoneRedundancy::AtLeast(x) => ZoneRedundancy::AtLeast(x), + } + } +} + +impl Into for ZoneRedundancy { + fn into(self) -> layout::ZoneRedundancy { + match self { + ZoneRedundancy::Maximum => layout::ZoneRedundancy::Maximum, + ZoneRedundancy::AtLeast(x) => layout::ZoneRedundancy::AtLeast(x), + } + } +} + +impl From for LayoutParameters { + fn from(x: layout::LayoutParameters) -> Self { + LayoutParameters { + zone_redundancy: x.zone_redundancy.into(), + } + } +} + +impl Into for LayoutParameters { + fn into(self) -> layout::LayoutParameters { + layout::LayoutParameters { + zone_redundancy: self.zone_redundancy.into(), + } + } +} diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index 3993b906..0cd1076e 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -14,6 +14,7 @@ mod router_v2; mod bucket; mod cluster; mod key; +mod layout; mod special; mod block; diff --git a/src/api/admin/node.rs b/src/api/admin/node.rs index 3c7b5c03..9994cfd0 100644 --- a/src/api/admin/node.rs +++ b/src/api/admin/node.rs @@ -1,17 +1,13 @@ -use std::collections::HashMap; use std::fmt::Write; use std::sync::Arc; use format_table::format_table_to_string; -use garage_util::data::*; use garage_util::error::Error as GarageError; use garage_table::replication::*; use garage_table::*; -use garage_rpc::layout::PARTITION_BITS; - use garage_model::garage::Garage; use crate::api::*; @@ -114,108 +110,6 @@ impl RequestHandler for LocalGetNodeStatisticsRequest { } } -impl RequestHandler for GetClusterStatisticsRequest { - type Response = GetClusterStatisticsResponse; - - // FIXME: return this as a JSON struct instead of text - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let mut ret = String::new(); - - // Gather storage node and free space statistics for current nodes - let layout = &garage.system.cluster_layout(); - let mut node_partition_count = HashMap::::new(); - for short_id in layout.current().ring_assignment_data.iter() { - let id = layout.current().node_id_vec[*short_id as usize]; - *node_partition_count.entry(id).or_default() += 1; - } - let node_info = garage - .system - .get_known_nodes() - .into_iter() - .map(|n| (n.id, n)) - .collect::>(); - - let mut table = vec![" ID\tHostname\tZone\tCapacity\tPart.\tDataAvail\tMetaAvail".into()]; - for (id, parts) in node_partition_count.iter() { - let info = node_info.get(id); - let status = info.map(|x| &x.status); - let role = layout.current().roles.get(id).and_then(|x| x.0.as_ref()); - let hostname = status.and_then(|x| x.hostname.as_deref()).unwrap_or("?"); - let zone = role.map(|x| x.zone.as_str()).unwrap_or("?"); - let capacity = role - .map(|x| x.capacity_string()) - .unwrap_or_else(|| "?".into()); - let avail_str = |x| match x { - Some((avail, total)) => { - let pct = (avail as f64) / (total as f64) * 100.; - let avail = bytesize::ByteSize::b(avail); - let total = bytesize::ByteSize::b(total); - format!("{}/{} ({:.1}%)", avail, total, pct) - } - None => "?".into(), - }; - let data_avail = avail_str(status.and_then(|x| x.data_disk_avail)); - let meta_avail = avail_str(status.and_then(|x| x.meta_disk_avail)); - table.push(format!( - " {:?}\t{}\t{}\t{}\t{}\t{}\t{}", - id, hostname, zone, capacity, parts, data_avail, meta_avail - )); - } - write!( - &mut ret, - "Storage nodes:\n{}", - format_table_to_string(table) - ) - .unwrap(); - - let meta_part_avail = node_partition_count - .iter() - .filter_map(|(id, parts)| { - node_info - .get(id) - .and_then(|x| x.status.meta_disk_avail) - .map(|c| c.0 / *parts) - }) - .collect::>(); - let data_part_avail = node_partition_count - .iter() - .filter_map(|(id, parts)| { - node_info - .get(id) - .and_then(|x| x.status.data_disk_avail) - .map(|c| c.0 / *parts) - }) - .collect::>(); - if !meta_part_avail.is_empty() && !data_part_avail.is_empty() { - let meta_avail = - bytesize::ByteSize(meta_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); - let data_avail = - bytesize::ByteSize(data_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS)); - writeln!( - &mut ret, - "\nEstimated available storage space cluster-wide (might be lower in practice):" - ) - .unwrap(); - if meta_part_avail.len() < node_partition_count.len() - || data_part_avail.len() < node_partition_count.len() - { - writeln!(&mut ret, " data: < {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: < {}", meta_avail).unwrap(); - writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap(); - } else { - writeln!(&mut ret, " data: {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: {}", meta_avail).unwrap(); - } - } - - Ok(GetClusterStatisticsResponse { freeform: ret }) - } -} - fn gather_table_stats(t: &Arc>) -> Result where F: TableSchema + 'static, From 46f620119b1718df1606fd903523d20b90cc9550 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 13:09:19 +0100 Subject: [PATCH 071/192] add model for admin key table --- Cargo.lock | 1 + src/api/admin/router_v2.rs | 4 +- src/model/Cargo.toml | 1 + src/model/admin_token_table.rs | 167 +++++++++++++++++++++++++++++++++ src/model/garage.rs | 13 +++ src/model/lib.rs | 1 + 6 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/model/admin_token_table.rs diff --git a/Cargo.lock b/Cargo.lock index 20820f7d..37e22f21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1467,6 +1467,7 @@ dependencies = [ name = "garage_model" version = "1.1.0" dependencies = [ + "argon2", "async-trait", "base64 0.21.7", "blake2", diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 9f6106e5..133f9c29 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -243,9 +243,7 @@ impl AdminApiRequest { /// Get the kind of authorization which is required to perform the operation. pub fn authorization_type(&self) -> Authorization { match self { - Self::Options(_) => Authorization::None, - Self::Health(_) => Authorization::None, - Self::CheckDomain(_) => Authorization::None, + Self::Options(_) | Self::Health(_) | Self::CheckDomain(_) => Authorization::None, Self::Metrics(_) => Authorization::MetricsToken, _ => Authorization::AdminToken, } diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 42ec8537..a990a191 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -21,6 +21,7 @@ garage_block.workspace = true garage_util.workspace = true garage_net.workspace = true +argon2.workspace = true async-trait.workspace = true blake2.workspace = true chrono.workspace = true diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs new file mode 100644 index 00000000..089c72e2 --- /dev/null +++ b/src/model/admin_token_table.rs @@ -0,0 +1,167 @@ +use garage_util::crdt::{self, Crdt}; + +use garage_table::{EmptyKey, Entry, TableSchema}; + +pub use crate::key_table::KeyFilter; + +mod v2 { + use garage_util::crdt; + use serde::{Deserialize, Serialize}; + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct AdminApiToken { + /// An admin API token is a bearer token of the following form: + /// `.` + /// Only the prefix is saved here, it is used as an identifier. + /// The entire API token is hashed and saved in `token_hash` in `state`. + pub prefix: String, + + /// If the token is not deleted, its parameters + pub state: crdt::Deletable, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct AdminApiTokenParams { + /// The entire API token hashed as a password + pub token_hash: String, + + /// User-defined name + pub name: crdt::Lww, + + /// The optional time of expiration of the token + pub expiration: crdt::Lww>, + + /// The scope of the token, i.e. list of authorized admin API calls + pub scope: crdt::Lww, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct AdminApiTokenScope(pub Vec); + + impl garage_util::migrate::InitialFormat for AdminApiToken { + const VERSION_MARKER: &'static [u8] = b"G2admtok"; + } +} + +pub use v2::*; + +impl Crdt for AdminApiTokenParams { + fn merge(&mut self, o: &Self) { + self.name.merge(&o.name); + self.expiration.merge(&o.expiration); + self.scope.merge(&o.scope); + } +} + +impl Crdt for AdminApiToken { + fn merge(&mut self, other: &Self) { + self.state.merge(&other.state); + } +} + +impl Crdt for AdminApiTokenScope { + fn merge(&mut self, other: &Self) { + self.0.retain(|x| other.0.contains(x)); + } +} + +impl AdminApiToken { + /// Create a new admin API token. + /// Returns the AdminApiToken object, which contains the hashed bearer token, + /// as well as the plaintext bearer token. + pub fn new(name: &str) -> (Self, String) { + use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, + }; + + let prefix = hex::encode(&rand::random::<[u8; 12]>()[..]); + let secret = hex::encode(&rand::random::<[u8; 32]>()[..]); + let token = format!("{}.{}", prefix, secret); + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hashed_token = argon2 + .hash_password(token.as_bytes(), &salt) + .expect("could not hash admin API token") + .to_string(); + + let ret = AdminApiToken { + prefix, + state: crdt::Deletable::present(AdminApiTokenParams { + token_hash: hashed_token, + name: crdt::Lww::new(name.to_string()), + expiration: crdt::Lww::new(None), + scope: crdt::Lww::new(AdminApiTokenScope(vec!["*".to_string()])), + }), + }; + + (ret, token) + } + + pub fn delete(prefix: String) -> Self { + Self { + prefix, + state: crdt::Deletable::Deleted, + } + } + + /// Returns true if this represents a deleted bucket + pub fn is_deleted(&self) -> bool { + self.state.is_deleted() + } + + /// Returns an option representing the params (None if in deleted state) + pub fn params(&self) -> Option<&AdminApiTokenParams> { + self.state.as_option() + } + + /// Mutable version of `.state()` + pub fn params_mut(&mut self) -> Option<&mut AdminApiTokenParams> { + self.state.as_option_mut() + } + + /// Scope, if not deleted, or empty slice + pub fn scope(&self) -> &[String] { + self.state + .as_option() + .map(|x| &x.scope.get().0[..]) + .unwrap_or_default() + } +} + +impl Entry for AdminApiToken { + fn partition_key(&self) -> &EmptyKey { + &EmptyKey + } + fn sort_key(&self) -> &String { + &self.prefix + } +} + +pub struct AdminApiTokenTable; + +impl TableSchema for AdminApiTokenTable { + const TABLE_NAME: &'static str = "admin_token"; + + type P = EmptyKey; + type S = String; + type E = AdminApiToken; + type Filter = KeyFilter; + + fn matches_filter(entry: &Self::E, filter: &Self::Filter) -> bool { + match filter { + KeyFilter::Deleted(df) => df.apply(entry.state.is_deleted()), + KeyFilter::MatchesAndNotDeleted(pat) => { + let pat = pat.to_lowercase(); + entry + .params() + .map(|p| { + entry.prefix.to_lowercase().starts_with(&pat) + || p.name.get().to_lowercase() == pat + }) + .unwrap_or(false) + } + } + } +} diff --git a/src/model/garage.rs b/src/model/garage.rs index 11c0d90f..95f7b577 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -24,6 +24,7 @@ use crate::s3::mpu_table::*; use crate::s3::object_table::*; use crate::s3::version_table::*; +use crate::admin_token_table::*; use crate::bucket_alias_table::*; use crate::bucket_table::*; use crate::helper; @@ -50,6 +51,8 @@ pub struct Garage { /// The block manager pub block_manager: Arc, + /// Table containing admin API keys + pub admin_token_table: Arc>, /// Table containing buckets pub bucket_table: Arc>, /// Table containing bucket aliases @@ -174,6 +177,14 @@ impl Garage { block_manager.register_bg_vars(&mut bg_vars); // ---- admin tables ---- + info!("Initialize admin_token_table..."); + let admin_token_table = Table::new( + AdminApiTokenTable, + control_rep_param.clone(), + system.clone(), + &db, + ); + info!("Initialize bucket_table..."); let bucket_table = Table::new(BucketTable, control_rep_param.clone(), system.clone(), &db); @@ -263,6 +274,7 @@ impl Garage { db, system, block_manager, + admin_token_table, bucket_table, bucket_alias_table, key_table, @@ -282,6 +294,7 @@ impl Garage { pub fn spawn_workers(self: &Arc, bg: &BackgroundRunner) -> Result<(), Error> { self.block_manager.spawn_workers(bg); + self.admin_token_table.spawn_workers(bg); self.bucket_table.spawn_workers(bg); self.bucket_alias_table.spawn_workers(bg); self.key_table.spawn_workers(bg); diff --git a/src/model/lib.rs b/src/model/lib.rs index 1939a7a9..b4dc1e81 100644 --- a/src/model/lib.rs +++ b/src/model/lib.rs @@ -5,6 +5,7 @@ pub mod permission; pub mod index_counter; +pub mod admin_token_table; pub mod bucket_alias_table; pub mod bucket_table; pub mod key_table; From 004eb94e14dad1544c661cbb049d6e538f6e3520 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 13:40:23 +0100 Subject: [PATCH 072/192] admin api: verify tokens using the new admin api token table --- src/api/admin/api_server.rs | 88 +++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 0e6afce2..98fc2529 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -1,8 +1,6 @@ use std::borrow::Cow; use std::sync::Arc; -use argon2::password_hash::PasswordHash; - use http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION}; use hyper::{body::Incoming as IncomingBody, Request, Response}; use serde::{Deserialize, Serialize}; @@ -15,10 +13,12 @@ use opentelemetry_prometheus::PrometheusExporter; use garage_model::garage::Garage; use garage_rpc::{Endpoint as RpcEndpoint, *}; +use garage_table::EmptyKey; use garage_util::background::BackgroundRunner; use garage_util::data::Uuid; use garage_util::error::Error as GarageError; use garage_util::socket_address::UnixOrTCPSocketAddress; +use garage_util::time::now_msec; use garage_api_common::generic_server::*; use garage_api_common::helpers::*; @@ -168,14 +168,13 @@ impl AdminApiServer { }, }; - if let Some(password_hash) = required_auth_hash { - match auth_header { - None => return Err(Error::forbidden("Authorization token must be provided")), - Some(authorization) => { - verify_bearer_token(&authorization, password_hash)?; - } - } - } + verify_authorization( + &self.garage, + required_auth_hash, + auth_header, + request.name(), + ) + .await?; match request { AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await, @@ -249,20 +248,65 @@ fn hash_bearer_token(token: &str) -> String { .to_string() } -fn verify_bearer_token(token: &hyper::http::HeaderValue, password_hash: &str) -> Result<(), Error> { - use argon2::{password_hash::PasswordVerifier, Argon2}; +async fn verify_authorization( + garage: &Garage, + required_token_hash: Option<&str>, + auth_header: Option, + endpoint_name: &str, +) -> Result<(), Error> { + use argon2::{password_hash::PasswordHash, password_hash::PasswordVerifier, Argon2}; - let parsed_hash = PasswordHash::new(&password_hash).unwrap(); + let invalid_msg = "Invalid bearer token"; - token - .to_str()? - .strip_prefix("Bearer ") - .and_then(|token| { - Argon2::default() - .verify_password(token.trim().as_bytes(), &parsed_hash) - .ok() - }) - .ok_or_else(|| Error::forbidden("Invalid authorization token"))?; + if let Some(token_hash_str) = required_token_hash { + let token = match &auth_header { + None => { + return Err(Error::forbidden( + "Bearer token must be provided in Authorization header", + )) + } + Some(authorization) => authorization + .to_str()? + .strip_prefix("Bearer ") + .ok_or_else(|| Error::forbidden("Invalid Authorization header"))? + .trim(), + }; + + let token_hash_string = if let Some((prefix, _)) = token.split_once('.') { + garage + .admin_token_table + .get(&EmptyKey, &prefix.to_string()) + .await? + .and_then(|k| k.state.into_option()) + .filter(|p| { + p.expiration + .get() + .map(|exp| now_msec() < exp) + .unwrap_or(true) + }) + .filter(|p| { + p.scope + .get() + .0 + .iter() + .any(|x| x == "*" || x == endpoint_name) + }) + .ok_or_else(|| Error::forbidden(invalid_msg))? + .token_hash + } else { + token_hash_str.to_string() + }; + + let token_hash = PasswordHash::new(&token_hash_string) + .ok_or_internal_error("Could not parse token hash")?; + + if Argon2::default() + .verify_password(token.as_bytes(), &token_hash) + .is_err() + { + return Err(Error::forbidden(invalid_msg)); + } + } Ok(()) } From ff6ec62d543d240b67dd90229bdb06a6cc55fd0f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 14:15:13 +0100 Subject: [PATCH 073/192] admin api: add metrics_require_token config option and update doc --- doc/book/reference-manual/configuration.md | 45 ++++++-- src/api/admin/api_server.rs | 128 ++++++++++----------- src/util/config.rs | 3 + 3 files changed, 97 insertions(+), 79 deletions(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index e0fc17bc..6e4daea0 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -80,6 +80,7 @@ add_host_to_metrics = true [admin] api_bind_addr = "0.0.0.0:3903" metrics_token = "BCAdFjoa9G0KJR0WXnHHm7fs1ZAbfpI8iIZ+Z/a2NgI=" +metrics_require_token = true admin_token = "UkLeGWEvHnXBqnueR3ISEMWpOnm40jH2tM2HnnL/0F4=" trace_sink = "http://localhost:4317" ``` @@ -145,6 +146,7 @@ The `[s3_web]` section: The `[admin]` section: [`api_bind_addr`](#admin_api_bind_addr), +[`metrics_require_token`](#admin_metrics_require_token), [`metrics_token`/`metrics_token_file`](#admin_metrics_token), [`admin_token`/`admin_token_file`](#admin_token), [`trace_sink`](#admin_trace_sink), @@ -767,10 +769,34 @@ See [administration API reference](@/documentation/reference-manual/admin-api.md Alternatively, since `v0.8.5`, a path can be used to create a unix socket. Note that for security reasons, the socket will have 0220 mode. Make sure to set user and group permissions accordingly. +#### `admin_token`, `admin_token_file` or `GARAGE_ADMIN_TOKEN`, `GARAGE_ADMIN_TOKEN_FILE` (env) {#admin_token} + +The token for accessing all administration functions on the admin endpoint, +with the exception of the metrics endpoint (see `metrics_token`). + +You can use any random string for this value. We recommend generating a random +token with `openssl rand -base64 32`. + +For Garage version earlier than `v2.0`, if this token is not set, +access to these endpoints is disabled entirely. + +Since Garage `v2.0`, additional admin API tokens can be defined dynamically +in your Garage cluster using administration commands. This new admin token system +is more flexible since it allows admin tokens to have an expiration date, +and to have a scope restricted to certain admin API functions. If `admin_token` +is set, it behaves as an admin token without expiration and with full scope. +Otherwise, only admin API tokens defined dynamically can be used. + +`admin_token` was introduced in Garage `v0.7.2`. +`admin_token_file` and the `GARAGE_ADMIN_TOKEN` environment variable are supported since Garage `v0.8.2`. + +`GARAGE_ADMIN_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`. + #### `metrics_token`, `metrics_token_file` or `GARAGE_METRICS_TOKEN`, `GARAGE_METRICS_TOKEN_FILE` (env) {#admin_metrics_token} -The token for accessing the Metrics endpoint. If this token is not set, the -Metrics endpoint can be accessed without access control. +The token for accessing the Prometheus metrics endpoint (`/metrics`). +If this token is not set, and unless `metrics_require_token` is set to `true`, +the metrics endpoint can be accessed without access control. You can use any random string for this value. We recommend generating a random token with `openssl rand -base64 32`. @@ -779,17 +805,12 @@ You can use any random string for this value. We recommend generating a random t `GARAGE_METRICS_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`. -#### `admin_token`, `admin_token_file` or `GARAGE_ADMIN_TOKEN`, `GARAGE_ADMIN_TOKEN_FILE` (env) {#admin_token} +#### `metrics_require_token` (since `v2.0.0`) {#admin_metrics_require_token} -The token for accessing all of the other administration endpoints. If this -token is not set, access to these endpoints is disabled entirely. - -You can use any random string for this value. We recommend generating a random token with `openssl rand -base64 32`. - -`admin_token` was introduced in Garage `v0.7.2`. -`admin_token_file` and the `GARAGE_ADMIN_TOKEN` environment variable are supported since Garage `v0.8.2`. - -`GARAGE_ADMIN_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`. +If this is set to `true`, accessing the metrics endpoint will always require +an access token. Valid tokens include the `metrics_token` if it is set, +and admin API token defined dynamicaly in Garage which have +the `Metrics` endpoint in their scope. #### `trace_sink` {#admin_trace_sink} diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 98fc2529..a214dfa7 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -99,6 +99,7 @@ pub struct AdminApiServer { #[cfg(feature = "metrics")] pub(crate) exporter: PrometheusExporter, metrics_token: Option, + metrics_require_token: bool, admin_token: Option, pub(crate) background: Arc, pub(crate) endpoint: Arc>, @@ -118,6 +119,7 @@ impl AdminApiServer { let cfg = &garage.config.admin; let metrics_token = cfg.metrics_token.as_deref().map(hash_bearer_token); let admin_token = cfg.admin_token.as_deref().map(hash_bearer_token); + let metrics_require_token = cfg.metrics_require_token; let endpoint = garage.system.netapp.endpoint(ADMIN_RPC_PATH.into()); let admin = Arc::new(Self { @@ -125,6 +127,7 @@ impl AdminApiServer { #[cfg(feature = "metrics")] exporter, metrics_token, + metrics_require_token, admin_token, background, endpoint, @@ -156,25 +159,19 @@ impl AdminApiServer { HttpEndpoint::New(_) => AdminApiRequest::from_request(req).await?, }; - let required_auth_hash = - match request.authorization_type() { - Authorization::None => None, - Authorization::MetricsToken => self.metrics_token.as_deref(), - Authorization::AdminToken => match self.admin_token.as_deref() { - None => return Err(Error::forbidden( - "Admin token isn't configured, admin API access is disabled for security.", - )), - Some(t) => Some(t), - }, - }; + let (global_token_hash, token_required) = match request.authorization_type() { + Authorization::None => (None, false), + Authorization::MetricsToken => ( + self.metrics_token.as_deref(), + self.metrics_token.is_some() || self.metrics_require_token, + ), + Authorization::AdminToken => (self.admin_token.as_deref(), true), + }; - verify_authorization( - &self.garage, - required_auth_hash, - auth_header, - request.name(), - ) - .await?; + if token_required { + verify_authorization(&self.garage, global_token_hash, auth_header, request.name()) + .await?; + } match request { AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await, @@ -250,7 +247,7 @@ fn hash_bearer_token(token: &str) -> String { async fn verify_authorization( garage: &Garage, - required_token_hash: Option<&str>, + global_token_hash: Option<&str>, auth_header: Option, endpoint_name: &str, ) -> Result<(), Error> { @@ -258,55 +255,52 @@ async fn verify_authorization( let invalid_msg = "Invalid bearer token"; - if let Some(token_hash_str) = required_token_hash { - let token = match &auth_header { - None => { - return Err(Error::forbidden( - "Bearer token must be provided in Authorization header", - )) - } - Some(authorization) => authorization - .to_str()? - .strip_prefix("Bearer ") - .ok_or_else(|| Error::forbidden("Invalid Authorization header"))? - .trim(), - }; - - let token_hash_string = if let Some((prefix, _)) = token.split_once('.') { - garage - .admin_token_table - .get(&EmptyKey, &prefix.to_string()) - .await? - .and_then(|k| k.state.into_option()) - .filter(|p| { - p.expiration - .get() - .map(|exp| now_msec() < exp) - .unwrap_or(true) - }) - .filter(|p| { - p.scope - .get() - .0 - .iter() - .any(|x| x == "*" || x == endpoint_name) - }) - .ok_or_else(|| Error::forbidden(invalid_msg))? - .token_hash - } else { - token_hash_str.to_string() - }; - - let token_hash = PasswordHash::new(&token_hash_string) - .ok_or_internal_error("Could not parse token hash")?; - - if Argon2::default() - .verify_password(token.as_bytes(), &token_hash) - .is_err() - { - return Err(Error::forbidden(invalid_msg)); + let token = match &auth_header { + None => { + return Err(Error::forbidden( + "Bearer token must be provided in Authorization header", + )) } - } + Some(authorization) => authorization + .to_str()? + .strip_prefix("Bearer ") + .ok_or_else(|| Error::forbidden("Invalid Authorization header"))? + .trim(), + }; + + let token_hash_string = if let Some((prefix, _)) = token.split_once('.') { + garage + .admin_token_table + .get(&EmptyKey, &prefix.to_string()) + .await? + .and_then(|k| k.state.into_option()) + .filter(|p| { + p.expiration + .get() + .map(|exp| now_msec() < exp) + .unwrap_or(true) + }) + .filter(|p| { + p.scope + .get() + .0 + .iter() + .any(|x| x == "*" || x == endpoint_name) + }) + .ok_or_else(|| Error::forbidden(invalid_msg))? + .token_hash + } else { + global_token_hash + .ok_or_else(|| Error::forbidden(invalid_msg))? + .to_string() + }; + + let token_hash = + PasswordHash::new(&token_hash_string).ok_or_internal_error("Could not parse token hash")?; + + Argon2::default() + .verify_password(token.as_bytes(), &token_hash) + .map_err(|_| Error::forbidden(invalid_msg))?; Ok(()) } diff --git a/src/util/config.rs b/src/util/config.rs index 73fc4ff4..47247718 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -198,6 +198,9 @@ pub struct AdminConfig { pub metrics_token: Option, /// File to read metrics token from pub metrics_token_file: Option, + /// Whether to require an access token for accessing the metrics endpoint + #[serde(default)] + pub metrics_require_token: bool, /// Bearer token to use to access Admin API endpoints pub admin_token: Option, From d067a40b3fe7b55fda1b8f5acdb43977a070f034 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 15:17:31 +0100 Subject: [PATCH 074/192] admin api: add functions to manage admin api tokens --- Cargo.lock | 1 + Cargo.toml | 2 +- src/api/admin/Cargo.toml | 1 + src/api/admin/admin_token.rs | 193 +++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 85 +++++++++++++++ src/api/admin/error.rs | 12 +- src/api/admin/key.rs | 21 +++- src/api/admin/lib.rs | 1 + src/api/admin/router_v2.rs | 6 + src/model/admin_token_table.rs | 4 +- src/model/helper/key.rs | 31 +----- 11 files changed, 319 insertions(+), 38 deletions(-) create mode 100644 src/api/admin/admin_token.rs diff --git a/Cargo.lock b/Cargo.lock index 37e22f21..b9d48116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1298,6 +1298,7 @@ dependencies = [ "argon2", "async-trait", "bytesize", + "chrono", "err-derive", "format_table", "futures", diff --git a/Cargo.toml b/Cargo.toml index d1cae350..b7830a7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ blake2 = "0.10" bytes = "1.0" bytesize = "1.1" cfg-if = "1.0" -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } crc32fast = "1.4" crc32c = "0.6" crypto-common = "0.1" diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index b4e2350a..65d9fda9 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -25,6 +25,7 @@ garage_api_common.workspace = true argon2.workspace = true async-trait.workspace = true bytesize.workspace = true +chrono.workspace = true err-derive.workspace = true hex.workspace = true paste.workspace = true diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs new file mode 100644 index 00000000..10a23a68 --- /dev/null +++ b/src/api/admin/admin_token.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use chrono::{DateTime, Utc}; + +use garage_table::*; +use garage_util::time::now_msec; + +use garage_model::admin_token_table::*; +use garage_model::garage::Garage; + +use crate::api::*; +use crate::error::*; +use crate::{Admin, RequestHandler}; + +impl RequestHandler for ListAdminTokensRequest { + type Response = ListAdminTokensResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let now = now_msec(); + + let res = garage + .admin_token_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::Deleted(DeletedFilter::NotDeleted)), + 10000, + EnumerationOrder::Forward, + ) + .await? + .iter() + .map(|t| admin_token_info_results(t, now)) + .collect::>(); + + Ok(ListAdminTokensResponse(res)) + } +} + +impl RequestHandler for GetAdminTokenInfoRequest { + type Response = GetAdminTokenInfoResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let token = match (self.id, self.search) { + (Some(id), None) => get_existing_admin_token(garage, &id).await?, + (None, Some(search)) => { + let candidates = garage + .admin_token_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::MatchesAndNotDeleted(search.to_string())), + 10, + EnumerationOrder::Forward, + ) + .await? + .into_iter() + .collect::>(); + if candidates.len() != 1 { + return Err(Error::bad_request(format!( + "{} matching admin tokens", + candidates.len() + ))); + } + candidates.into_iter().next().unwrap() + } + _ => { + return Err(Error::bad_request( + "Either id or search must be provided (but not both)", + )); + } + }; + + Ok(admin_token_info_results(&token, now_msec())) + } +} + +impl RequestHandler for CreateAdminTokenRequest { + type Response = CreateAdminTokenResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let (mut token, secret) = if self.0.name.is_some() { + AdminApiToken::new("") + } else { + AdminApiToken::new(&format!("token_{}", Utc::now().format("%Y%m%d_%H%M"))) + }; + + apply_token_updates(&mut token, self.0); + + garage.admin_token_table.insert(&token).await?; + + Ok(CreateAdminTokenResponse { + secret_token: secret, + info: admin_token_info_results(&token, now_msec()), + }) + } +} + +impl RequestHandler for UpdateAdminTokenRequest { + type Response = UpdateAdminTokenResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut token = get_existing_admin_token(&garage, &self.id).await?; + + apply_token_updates(&mut token, self.body); + + garage.admin_token_table.insert(&token).await?; + + Ok(UpdateAdminTokenResponse(admin_token_info_results( + &token, + now_msec(), + ))) + } +} + +impl RequestHandler for DeleteAdminTokenRequest { + type Response = DeleteAdminTokenResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let token = get_existing_admin_token(&garage, &self.id).await?; + + garage + .admin_token_table + .insert(&AdminApiToken::delete(token.prefix)) + .await?; + + Ok(DeleteAdminTokenResponse) + } +} + +// ---- helpers ---- + +fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInfoResponse { + let params = token.params().unwrap(); + + GetAdminTokenInfoResponse { + id: token.prefix.clone(), + name: params.name.get().to_string(), + expiration: params.expiration.get().map(|x| { + DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") + }), + expired: params + .expiration + .get() + .map(|exp| now > exp) + .unwrap_or(false), + scope: params.scope.get().0.clone(), + } +} + +async fn get_existing_admin_token(garage: &Garage, id: &String) -> Result { + garage + .admin_token_table + .get(&EmptyKey, id) + .await? + .filter(|k| !k.state.is_deleted()) + .ok_or_else(|| Error::NoSuchAdminToken(id.to_string())) +} + +fn apply_token_updates(token: &mut AdminApiToken, updates: UpdateAdminTokenRequestBody) { + let params = token.params_mut().unwrap(); + + if let Some(name) = updates.name { + params.name.update(name); + } + if let Some(expiration) = updates.expiration { + params + .expiration + .update(Some(expiration.timestamp_millis() as u64)); + } + if let Some(scope) = updates.scope { + params.scope.update(AdminApiTokenScope(scope)); + } +} diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 78706ce3..13b2c3b1 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -49,6 +49,13 @@ admin_endpoints![ GetClusterStatistics, ConnectClusterNodes, + // Admin tokens operations + ListAdminTokens, + GetAdminTokenInfo, + CreateAdminToken, + UpdateAdminToken, + DeleteAdminToken, + // Layout operations GetClusterLayout, GetClusterLayoutHistory, @@ -282,6 +289,84 @@ pub struct ConnectNodeResponse { pub error: Option, } +// ********************************************** +// Admin token operations +// ********************************************** + +// ---- ListAdminTokens ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListAdminTokensRequest; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListAdminTokensResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListAdminTokensResponseItem { + pub id: String, + pub name: String, +} + +// ---- GetAdminTokenInfo ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdminTokenInfoRequest { + pub id: Option, + pub search: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAdminTokenInfoResponse { + pub id: String, + pub name: String, + pub expiration: Option>, + pub expired: bool, + pub scope: Vec, +} + +// ---- CreateAdminToken ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAdminTokenRequest(pub UpdateAdminTokenRequestBody); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAdminTokenResponse { + pub secret_token: String, + #[serde(flatten)] + pub info: GetAdminTokenInfoResponse, +} + +// ---- UpdateAdminToken ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAdminTokenRequest { + pub id: String, + pub body: UpdateAdminTokenRequestBody, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAdminTokenRequestBody { + pub name: Option, + pub expiration: Option>, + pub scope: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAdminTokenResponse(pub GetAdminTokenInfoResponse); + +// ---- DeleteAdminToken ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteAdminTokenRequest { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteAdminTokenResponse; + // ********************************************** // Layout operations // ********************************************** diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index d7ea7dc9..f12a936e 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -21,6 +21,10 @@ pub enum Error { Common(#[error(source)] CommonError), // Category: cannot process + /// The admin API token does not exist + #[error(display = "Admin token not found: {}", _0)] + NoSuchAdminToken(String), + /// The API access key does not exist #[error(display = "Access key not found: {}", _0)] NoSuchAccessKey(String), @@ -60,6 +64,7 @@ impl Error { pub fn code(&self) -> &'static str { match self { Error::Common(c) => c.aws_code(), + Error::NoSuchAdminToken(_) => "NoSuchAdminToken", Error::NoSuchAccessKey(_) => "NoSuchAccessKey", Error::NoSuchWorker(_) => "NoSuchWorker", Error::NoSuchBlock(_) => "NoSuchBlock", @@ -73,9 +78,10 @@ impl ApiError for Error { fn http_status_code(&self) -> StatusCode { match self { Error::Common(c) => c.http_status_code(), - Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) | Error::NoSuchBlock(_) => { - StatusCode::NOT_FOUND - } + Error::NoSuchAdminToken(_) + | Error::NoSuchAccessKey(_) + | Error::NoSuchWorker(_) + | Error::NoSuchBlock(_) => StatusCode::NOT_FOUND, Error::KeyAlreadyExists(_) => StatusCode::CONFLICT, } } diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index dc6ae4e9..d1a49ab3 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -46,10 +46,25 @@ impl RequestHandler for GetKeyInfoRequest { let key = match (self.id, self.search) { (Some(id), None) => garage.key_helper().get_existing_key(&id).await?, (None, Some(search)) => { - garage - .key_helper() - .get_existing_matching_key(&search) + let candidates = garage + .key_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::MatchesAndNotDeleted(search.to_string())), + 10, + EnumerationOrder::Forward, + ) .await? + .into_iter() + .collect::>(); + if candidates.len() != 1 { + return Err(Error::bad_request(format!( + "{} matching keys", + candidates.len() + ))); + } + candidates.into_iter().next().unwrap() } _ => { return Err(Error::bad_request( diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index 0cd1076e..dd164497 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -11,6 +11,7 @@ mod router_v0; mod router_v1; mod router_v2; +mod admin_token; mod bucket; mod cluster; mod key; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 133f9c29..73f98308 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -34,6 +34,12 @@ impl AdminApiRequest { GET GetClusterStatus (), GET GetClusterHealth (), POST ConnectClusterNodes (body), + // Admin token endpoints + GET ListAdminTokens (), + GET GetAdminTokenInfo (query_opt::id, query_opt::search), + POST CreateAdminToken (body), + POST UpdateAdminToken (body_field, query::id), + POST DeleteAdminToken (query::id), // Layout endpoints GET GetClusterLayout (), GET GetClusterLayoutHistory (), diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index 089c72e2..45532e54 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -1,3 +1,5 @@ +use base64::prelude::*; + use garage_util::crdt::{self, Crdt}; use garage_table::{EmptyKey, Entry, TableSchema}; @@ -76,7 +78,7 @@ impl AdminApiToken { }; let prefix = hex::encode(&rand::random::<[u8; 12]>()[..]); - let secret = hex::encode(&rand::random::<[u8; 32]>()[..]); + let secret = BASE64_URL_SAFE_NO_PAD.encode(&rand::random::<[u8; 32]>()[..]); let token = format!("{}.{}", prefix, secret); let salt = SaltString::generate(&mut OsRng); diff --git a/src/model/helper/key.rs b/src/model/helper/key.rs index b8a99d55..00d8d5c6 100644 --- a/src/model/helper/key.rs +++ b/src/model/helper/key.rs @@ -3,7 +3,7 @@ use garage_util::error::OkOrMessage; use crate::garage::Garage; use crate::helper::error::*; -use crate::key_table::{Key, KeyFilter}; +use crate::key_table::Key; pub struct KeyHelper<'a>(pub(crate) &'a Garage); @@ -33,33 +33,4 @@ impl<'a> KeyHelper<'a> { .filter(|b| !b.state.is_deleted()) .ok_or_else(|| Error::NoSuchAccessKey(key_id.to_string())) } - - /// Returns a Key if it is present in key table, - /// looking it up by key ID or by a match on its name, - /// only if it is in non-deleted state. - /// Querying a non-existing key ID or a deleted key - /// returns a bad request error. - pub async fn get_existing_matching_key(&self, pattern: &str) -> Result { - let candidates = self - .0 - .key_table - .get_range( - &EmptyKey, - None, - Some(KeyFilter::MatchesAndNotDeleted(pattern.to_string())), - 10, - EnumerationOrder::Forward, - ) - .await? - .into_iter() - .collect::>(); - if candidates.len() != 1 { - Err(Error::BadRequest(format!( - "{} matching keys", - candidates.len() - ))) - } else { - Ok(candidates.into_iter().next().unwrap()) - } - } } From 9511b20153343d52fbc82dac377e040635c4e6c8 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 15:38:38 +0100 Subject: [PATCH 075/192] admin api: add openapi spec for admin token management functions --- doc/api/garage-admin-v2.json | 257 +++++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 35 +++-- src/api/admin/openapi.rs | 82 +++++++++++ 3 files changed, 363 insertions(+), 11 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 97de3a71..f3310256 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -225,6 +225,40 @@ } } }, + "/v2/CreateAdminToken": { + "post": { + "tags": [ + "Admin API token" + ], + "description": "Creates a new admin API token", + "operationId": "CreateAdminToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdminTokenRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Admin token has been created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAdminTokenResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/CreateBucket": { "post": { "tags": [ @@ -325,6 +359,31 @@ } } }, + "/v2/DeleteAdminToken": { + "post": { + "tags": [ + "Admin API token" + ], + "description": "Delete an admin API token from the cluster, revoking all its permissions.", + "operationId": "DeleteAdminToken", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Admin API token ID", + "required": true + } + ], + "responses": { + "200": { + "description": "Admin token has been deleted" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/DeleteBucket": { "post": { "tags": [ @@ -415,6 +474,44 @@ } } }, + "/v2/GetAdminTokenInfo": { + "get": { + "tags": [ + "Admin API token" + ], + "description": "\nReturn information about a specific admin API token.\nYou can search by specifying the exact token identifier (`id`) or by specifying a pattern (`search`).\n ", + "operationId": "GetAdminTokenInfo", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Admin API token ID", + "required": true + }, + { + "name": "search", + "in": "path", + "description": "Partial token ID or name to search for", + "required": true + } + ], + "responses": { + "200": { + "description": "Information about the admin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/GetBlockInfo": { "post": { "tags": [ @@ -886,6 +983,30 @@ } } }, + "/v2/ListAdminTokens": { + "get": { + "tags": [ + "Admin API token" + ], + "description": "Returns all admin API tokens in the cluster.", + "operationId": "ListAdminTokens", + "responses": { + "200": { + "description": "Returns info about all admin API tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAdminTokensResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/ListBlockErrors": { "get": { "tags": [ @@ -1216,6 +1337,48 @@ } } }, + "/v2/UpdateAdminToken": { + "post": { + "tags": [ + "Admin API token" + ], + "description": "\nUpdates information about the specified admin API token.\n ", + "operationId": "UpdateAdminToken", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Admin API token ID", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdminTokenRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Admin token has been updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdminTokenResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/UpdateBucket": { "post": { "tags": [ @@ -1775,6 +1938,25 @@ } } }, + "CreateAdminTokenResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + }, + { + "type": "object", + "required": [ + "secretToken" + ], + "properties": { + "secretToken": { + "type": "string", + "description": "The secret bearer token. **CAUTION:** This token will be shown only\nONCE, so this value MUST be remembered somewhere, or the token\nwill be unusable." + } + } + } + ] + }, "CreateBucketLocalAlias": { "type": "object", "required": [ @@ -1858,6 +2040,43 @@ } } }, + "GetAdminTokenInfoResponse": { + "type": "object", + "required": [ + "id", + "name", + "expired", + "scope" + ], + "properties": { + "expiration": { + "type": [ + "string", + "null" + ], + "description": "Expiration time and date, formatted according to RFC 3339" + }, + "expired": { + "type": "boolean", + "description": "Whether this admin token is expired already" + }, + "id": { + "type": "string", + "description": "Identifier of the admin token (which is also a prefix of the full bearer token)" + }, + "name": { + "type": "string", + "description": "Name of the admin API token" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scope of the admin API token, a list of admin endpoint names (such as\n`GetClusterStatus`, etc), or the special value `*` to allow all\nadmin endpoints" + } + } + }, "GetBucketInfoKey": { "type": "object", "required": [ @@ -2325,6 +2544,12 @@ } } }, + "ListAdminTokensResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + } + }, "ListBucketsResponse": { "type": "array", "items": { @@ -3404,6 +3629,38 @@ "cancel" ] }, + "UpdateAdminTokenRequestBody": { + "type": "object", + "properties": { + "expiration": { + "type": [ + "string", + "null" + ], + "description": "Expiration time and date, formatted according to RFC 3339" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Name of the admin API token" + }, + "scope": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Scope of the admin API token, a list of admin endpoint names (such as\n`GetClusterStatus`, etc), or the special value `*` to allow all\nadmin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or\n`UpdateAdminToken` trivially allows for privilege escalation, and is thus\nfunctionnally equivalent to granting a scope of `*`." + } + } + }, + "UpdateAdminTokenResponse": { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + }, "UpdateBucketRequestBody": { "type": "object", "properties": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 13b2c3b1..f002efad 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -298,15 +298,9 @@ pub struct ConnectNodeResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListAdminTokensRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ListAdminTokensResponse(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListAdminTokensResponseItem { - pub id: String, - pub name: String, -} - // ---- GetAdminTokenInfo ---- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -315,13 +309,21 @@ pub struct GetAdminTokenInfoRequest { pub search: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetAdminTokenInfoResponse { + /// Identifier of the admin token (which is also a prefix of the full bearer token) pub id: String, + /// Name of the admin API token pub name: String, + /// Expiration time and date, formatted according to RFC 3339 + #[schema(value_type = Option)] pub expiration: Option>, + /// Whether this admin token is expired already pub expired: bool, + /// Scope of the admin API token, a list of admin endpoint names (such as + /// `GetClusterStatus`, etc), or the special value `*` to allow all + /// admin endpoints pub scope: Vec, } @@ -330,9 +332,12 @@ pub struct GetAdminTokenInfoResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateAdminTokenRequest(pub UpdateAdminTokenRequestBody); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateAdminTokenResponse { + /// The secret bearer token. **CAUTION:** This token will be shown only + /// ONCE, so this value MUST be remembered somewhere, or the token + /// will be unusable. pub secret_token: String, #[serde(flatten)] pub info: GetAdminTokenInfoResponse, @@ -346,15 +351,23 @@ pub struct UpdateAdminTokenRequest { pub body: UpdateAdminTokenRequestBody, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateAdminTokenRequestBody { + /// Name of the admin API token pub name: Option, + /// Expiration time and date, formatted according to RFC 3339 + #[schema(value_type = Option)] pub expiration: Option>, + /// Scope of the admin API token, a list of admin endpoint names (such as + /// `GetClusterStatus`, etc), or the special value `*` to allow all + /// admin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or + /// `UpdateAdminToken` trivially allows for privilege escalation, and is thus + /// functionnally equivalent to granting a scope of `*`. pub scope: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateAdminTokenResponse(pub GetAdminTokenInfoResponse); // ---- DeleteAdminToken ---- diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 01a694e5..24319817 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -66,6 +66,82 @@ fn GetClusterStatistics() -> () {} )] fn ConnectClusterNodes() -> () {} +// ********************************************** +// Admin API token operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/ListAdminTokens", + tag = "Admin API token", + description = "Returns all admin API tokens in the cluster.", + responses( + (status = 200, description = "Returns info about all admin API tokens", body = ListAdminTokensResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ListAdminTokens() -> () {} + +#[utoipa::path(get, + path = "/v2/GetAdminTokenInfo", + tag = "Admin API token", + description = " +Return information about a specific admin API token. +You can search by specifying the exact token identifier (`id`) or by specifying a pattern (`search`). + ", + params( + ("id", description = "Admin API token ID"), + ("search", description = "Partial token ID or name to search for"), + ), + responses( + (status = 200, description = "Information about the admin token", body = GetAdminTokenInfoResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetAdminTokenInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/CreateAdminToken", + tag = "Admin API token", + description = "Creates a new admin API token", + request_body = UpdateAdminTokenRequestBody, + responses( + (status = 200, description = "Admin token has been created", body = CreateAdminTokenResponse), + (status = 500, description = "Internal server error") + ), +)] +fn CreateAdminToken() -> () {} + +#[utoipa::path(post, + path = "/v2/UpdateAdminToken", + tag = "Admin API token", + description = " +Updates information about the specified admin API token. + ", + request_body = UpdateAdminTokenRequestBody, + params( + ("id", description = "Admin API token ID"), + ), + responses( + (status = 200, description = "Admin token has been updated", body = UpdateAdminTokenResponse), + (status = 500, description = "Internal server error") + ), +)] +fn UpdateAdminToken() -> () {} + +#[utoipa::path(post, + path = "/v2/DeleteAdminToken", + tag = "Admin API token", + description = "Delete an admin API token from the cluster, revoking all its permissions.", + params( + ("id", description = "Admin API token ID"), + ), + responses( + (status = 200, description = "Admin token has been deleted"), + (status = 500, description = "Internal server error") + ), +)] +fn DeleteAdminToken() -> () {} + // ********************************************** // Layout operations // ********************************************** @@ -723,6 +799,12 @@ impl Modify for SecurityAddon { GetClusterStatus, GetClusterStatistics, ConnectClusterNodes, + // Admin token operations + ListAdminTokens, + GetAdminTokenInfo, + CreateAdminToken, + UpdateAdminToken, + DeleteAdminToken, // Layout operations GetClusterLayout, GetClusterLayoutHistory, From ec0da3b644ca7f8c5a410f4ffea38dbb6309e042 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 15:57:29 +0100 Subject: [PATCH 076/192] admin api: mention admin_token and metrics_token in ListAdminTokensResponse --- doc/api/garage-admin-v2.json | 6 ++++-- src/api/admin/admin_token.rs | 30 ++++++++++++++++++++++++++++-- src/api/admin/api.rs | 2 +- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index f3310256..6ede967b 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2043,7 +2043,6 @@ "GetAdminTokenInfoResponse": { "type": "object", "required": [ - "id", "name", "expired", "scope" @@ -2061,7 +2060,10 @@ "description": "Whether this admin token is expired already" }, "id": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Identifier of the admin token (which is also a prefix of the full bearer token)" }, "name": { diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index 10a23a68..aca7a519 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -22,7 +22,7 @@ impl RequestHandler for ListAdminTokensRequest { ) -> Result { let now = now_msec(); - let res = garage + let mut res = garage .admin_token_table .get_range( &EmptyKey, @@ -36,6 +36,32 @@ impl RequestHandler for ListAdminTokensRequest { .map(|t| admin_token_info_results(t, now)) .collect::>(); + if garage.config.admin.admin_token.is_some() { + res.insert( + 0, + GetAdminTokenInfoResponse { + id: None, + name: "admin_token (from daemon configuration)".into(), + expiration: None, + expired: false, + scope: vec!["*".into()], + }, + ); + } + + if garage.config.admin.metrics_token.is_some() { + res.insert( + 1, + GetAdminTokenInfoResponse { + id: None, + name: "metrics_token (from daemon configuration)".into(), + expiration: None, + expired: false, + scope: vec!["Metrics".into()], + }, + ); + } + Ok(ListAdminTokensResponse(res)) } } @@ -153,7 +179,7 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf let params = token.params().unwrap(); GetAdminTokenInfoResponse { - id: token.prefix.clone(), + id: Some(token.prefix.clone()), name: params.name.get().to_string(), expiration: params.expiration.get().map(|x| { DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index f002efad..94cb7377 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -313,7 +313,7 @@ pub struct GetAdminTokenInfoRequest { #[serde(rename_all = "camelCase")] pub struct GetAdminTokenInfoResponse { /// Identifier of the admin token (which is also a prefix of the full bearer token) - pub id: String, + pub id: Option, /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 From 1bd7689301c843119b6f0c34851729e89b768803 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 18:09:24 +0100 Subject: [PATCH 077/192] cli: add functions to manage admin api tokens --- src/garage/Cargo.toml | 1 + src/garage/cli/remote/admin_token.rs | 227 +++++++++++++++++++++++++++ src/garage/cli/remote/mod.rs | 2 + src/garage/cli/structs.rs | 126 +++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 src/garage/cli/remote/admin_token.rs diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index ba747fdf..045a6174 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -38,6 +38,7 @@ garage_web.workspace = true backtrace.workspace = true bytes.workspace = true bytesize.workspace = true +chrono.workspace = true timeago.workspace = true parse_duration.workspace = true hex.workspace = true diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs new file mode 100644 index 00000000..464480a1 --- /dev/null +++ b/src/garage/cli/remote/admin_token.rs @@ -0,0 +1,227 @@ +use format_table::format_table; + +use chrono::Utc; + +use garage_util::error::*; + +use garage_api_admin::api::*; + +use crate::cli::remote::*; +use crate::cli::structs::*; + +impl Cli { + pub async fn cmd_admin_token(&self, cmd: AdminTokenOperation) -> Result<(), Error> { + match cmd { + AdminTokenOperation::List => self.cmd_list_admin_tokens().await, + AdminTokenOperation::Info { api_token } => self.cmd_admin_token_info(api_token).await, + AdminTokenOperation::Create(opt) => self.cmd_create_admin_token(opt).await, + AdminTokenOperation::Rename { + api_token, + new_name, + } => self.cmd_rename_admin_token(api_token, new_name).await, + AdminTokenOperation::Set(opt) => self.cmd_update_admin_token(opt).await, + AdminTokenOperation::Delete { api_token, yes } => { + self.cmd_delete_admin_token(api_token, yes).await + } + AdminTokenOperation::DeleteExpired { yes } => { + self.cmd_delete_expired_admin_tokens(yes).await + } + } + } + + pub async fn cmd_list_admin_tokens(&self) -> Result<(), Error> { + let list = self.api_request(ListAdminTokensRequest).await?; + + let mut table = vec!["ID\tNAME\tEXPIRATION\tSCOPE".to_string()]; + for tok in list.0.iter() { + let scope = if tok.scope.len() > 1 { + format!("[{}]", tok.scope.len()) + } else { + tok.scope.get(0).cloned().unwrap_or_default() + }; + let exp = if tok.expired { + "expired".to_string() + } else { + tok.expiration + .map(|x| x.to_string()) + .unwrap_or("never".into()) + }; + table.push(format!( + "{}\t{}\t{}\t{}\t", + tok.id.as_deref().unwrap_or("-"), + tok.name, + exp, + scope, + )); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_admin_token_info(&self, search: String) -> Result<(), Error> { + let info = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(search), + }) + .await?; + + print_token_info(&info); + + Ok(()) + } + + pub async fn cmd_create_admin_token(&self, opt: AdminTokenCreateOp) -> Result<(), Error> { + // TODO + let res = self + .api_request(CreateAdminTokenRequest(UpdateAdminTokenRequestBody { + name: opt.name, + expiration: opt + .expires_in + .map(|x| parse_duration::parse::parse(&x)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter")? + .map(|dur| Utc::now() + dur), + scope: opt.scope.map(|s| { + s.split(",") + .map(|x| x.trim().to_string()) + .collect::>() + }), + })) + .await?; + + if opt.quiet { + println!("{}", res.secret_token); + } else { + println!("This is your secret bearer token, it will not be shown again by Garage:"); + println!("\n {}\n", res.secret_token); + print_token_info(&res.info); + } + + Ok(()) + } + + pub async fn cmd_rename_admin_token(&self, old: String, new: String) -> Result<(), Error> { + let token = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(old), + }) + .await?; + + let info = self + .api_request(UpdateAdminTokenRequest { + id: token.id.unwrap(), + body: UpdateAdminTokenRequestBody { + name: Some(new), + expiration: None, + scope: None, + }, + }) + .await?; + + print_token_info(&info.0); + + Ok(()) + } + + pub async fn cmd_update_admin_token(&self, opt: AdminTokenSetOp) -> Result<(), Error> { + let token = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(opt.api_token), + }) + .await?; + + let info = self + .api_request(UpdateAdminTokenRequest { + id: token.id.unwrap(), + body: UpdateAdminTokenRequestBody { + name: None, + expiration: opt + .expires_in + .map(|x| parse_duration::parse::parse(&x)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter")? + .map(|dur| Utc::now() + dur), + scope: opt.scope.map(|s| { + s.split(",") + .map(|x| x.trim().to_string()) + .collect::>() + }), + }, + }) + .await?; + + print_token_info(&info.0); + + Ok(()) + } + + pub async fn cmd_delete_admin_token(&self, token: String, yes: bool) -> Result<(), Error> { + let token = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(token), + }) + .await?; + + let id = token.id.unwrap(); + + if !yes { + return Err(Error::Message(format!( + "Add the --yes flag to delete API token `{}` ({})", + token.name, id + ))); + } + + self.api_request(DeleteAdminTokenRequest { id }).await?; + + println!("Admin API token has been deleted."); + + Ok(()) + } + + pub async fn cmd_delete_expired_admin_tokens(&self, yes: bool) -> Result<(), Error> { + let mut list = self.api_request(ListAdminTokensRequest).await?.0; + + list.retain(|tok| tok.expired); + + if !yes { + return Err(Error::Message(format!( + "This would delete {} admin API tokens, add the --yes flag to proceed.", + list.len(), + ))); + } + + for token in list.iter() { + let id = token.id.clone().unwrap(); + println!("Deleting token `{}` ({})", token.name, id); + self.api_request(DeleteAdminTokenRequest { id }).await?; + } + + println!("{} admin API tokens have been deleted.", list.len()); + + Ok(()) + } +} + +fn print_token_info(token: &GetAdminTokenInfoResponse) { + format_table(vec![ + format!("ID:\t{}", token.id.as_deref().unwrap_or("-")), + format!("Name:\t{}", token.name), + format!( + "Validity:\t{}", + token.expired.then_some("EXPIRED").unwrap_or("valid") + ), + format!( + "Expiration:\t{}", + token + .expiration + .map(|x| x.to_string()) + .unwrap_or("never".into()) + ), + format!("Scope:\t{}", token.scope.to_vec().join(", ")), + ]); +} diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs index 40673b91..237b6db9 100644 --- a/src/garage/cli/remote/mod.rs +++ b/src/garage/cli/remote/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_token; pub mod bucket; pub mod cluster; pub mod key; @@ -35,6 +36,7 @@ impl Cli { } Command::Layout(layout_opt) => self.layout_command_dispatch(layout_opt).await, Command::Bucket(bo) => self.cmd_bucket(bo).await, + Command::AdminToken(to) => self.cmd_admin_token(to).await, Command::Key(ko) => self.cmd_key(ko).await, Command::Worker(wo) => self.cmd_worker(wo).await, Command::Block(bo) => self.cmd_block(bo).await, diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 0af92c35..0b0a8b94 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -30,6 +30,10 @@ pub enum Command { #[structopt(name = "key", version = garage_version())] Key(KeyOperation), + /// Operations on admin API tokens + #[structopt(name = "admin-token", version = garage_version())] + AdminToken(AdminTokenOperation), + /// Start repair of node data on remote node #[structopt(name = "repair", version = garage_version())] Repair(RepairOpt), @@ -64,6 +68,10 @@ pub enum Command { AdminApiSchema, } +// ------------------------- +// ---- garage node ... ---- +// ------------------------- + #[derive(StructOpt, Debug)] pub enum NodeOperation { /// Print the full node ID (public key) of this Garage node, and its publicly reachable IP @@ -91,6 +99,10 @@ pub struct ConnectNodeOpt { pub(crate) node: String, } +// --------------------------- +// ---- garage layout ... ---- +// --------------------------- + #[derive(StructOpt, Debug)] pub enum LayoutOperation { /// Assign role to Garage node @@ -193,6 +205,10 @@ pub struct SkipDeadNodesOpt { pub(crate) allow_missing_data: bool, } +// --------------------------- +// ---- garage bucket ... ---- +// --------------------------- + #[derive(StructOpt, Debug)] pub enum BucketOperation { /// List buckets @@ -350,6 +366,10 @@ pub struct CleanupIncompleteUploadsOpt { pub buckets: Vec, } +// ------------------------ +// ---- garage key ... ---- +// ------------------------ + #[derive(StructOpt, Debug)] pub enum KeyOperation { /// List keys @@ -447,6 +467,92 @@ pub struct KeyImportOpt { pub yes: bool, } +// -------------------------------- +// ---- garage admin-token ... ---- +// -------------------------------- + +#[derive(StructOpt, Debug)] +pub enum AdminTokenOperation { + /// List all admin API tokens + #[structopt(name = "list", version = garage_version())] + List, + + /// Fetch info about a specific admin API token + #[structopt(name = "info", version = garage_version())] + Info { + /// Name or prefix of the ID of the token to look up + api_token: String, + }, + + /// Create new admin API token + #[structopt(name = "create", version = garage_version())] + Create(AdminTokenCreateOp), + + /// Rename an admin API token + #[structopt(name = "rename", version = garage_version())] + Rename { + /// Name or prefix of the ID of the token to rename + api_token: String, + /// New name of the admintoken + new_name: String, + }, + + /// Set parameters for an admin API token + #[structopt(name = "set", version = garage_version())] + Set(AdminTokenSetOp), + + /// Delete an admin API token + #[structopt(name = "delete", version = garage_version())] + Delete { + /// Name or prefix of the ID of the token to delete + api_token: String, + /// Confirm deletion + #[structopt(long = "yes")] + yes: bool, + }, + + /// Delete all expired admin API tokens + #[structopt(name = "delete-expired", version = garage_version())] + DeleteExpired { + /// Confirm deletion + #[structopt(long = "yes")] + yes: bool, + }, +} + +#[derive(StructOpt, Debug, Clone)] +pub struct AdminTokenCreateOp { + /// Set a name for the token + pub name: Option, + /// Set an expiration time for the token (see docs.rs/parse_duration for date + /// format) + #[structopt(long = "expires-in")] + pub expires_in: Option, + /// Set a limited scope for the token (by default, `*`) + #[structopt(long = "scope")] + pub scope: Option, + /// Print only the newly generated API token to stdout + #[structopt(short = "q", long = "quiet")] + pub quiet: bool, +} + +#[derive(StructOpt, Debug, Clone)] +pub struct AdminTokenSetOp { + /// Name or prefix of the ID of the token to modify + pub api_token: String, + /// Set an expiration time for the token (see docs.rs/parse_duration for date + /// format) + #[structopt(long = "expires-in")] + pub expires_in: Option, + /// Set a limited scope for the token + #[structopt(long = "scope")] + pub scope: Option, +} + +// --------------------------- +// ---- garage repair ... ---- +// --------------------------- + #[derive(StructOpt, Debug, Clone)] pub struct RepairOpt { /// Launch repair operation on all nodes @@ -508,6 +614,10 @@ pub enum ScrubCmd { Cancel, } +// ----------------------------------- +// ---- garage offline-repair ... ---- +// ----------------------------------- + #[derive(StructOpt, Debug, Clone)] pub struct OfflineRepairOpt { /// Confirm the launch of the repair operation @@ -529,6 +639,10 @@ pub enum OfflineRepairWhat { ObjectCounters, } +// -------------------------- +// ---- garage stats ... ---- +// -------------------------- + #[derive(StructOpt, Debug, Clone)] pub struct StatsOpt { /// Gather statistics from all nodes @@ -536,6 +650,10 @@ pub struct StatsOpt { pub all_nodes: bool, } +// --------------------------- +// ---- garage worker ... ---- +// --------------------------- + #[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum WorkerOperation { /// List all workers on Garage node @@ -579,6 +697,10 @@ pub struct WorkerListOpt { pub errors: bool, } +// -------------------------- +// ---- garage block ... ---- +// -------------------------- + #[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum BlockOperation { /// List all blocks that currently have a resync error @@ -611,6 +733,10 @@ pub enum BlockOperation { }, } +// ------------------------- +// ---- garage meta ... ---- +// ------------------------- + #[derive(StructOpt, Debug, Eq, PartialEq, Clone, Copy)] pub enum MetaOperation { /// Save a snapshot of the metadata db file From 22c0420607a46750895e533667d9fb9efd4956fc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 18:21:00 +0100 Subject: [PATCH 078/192] admin api: specify date-time format in openapi spec --- Cargo.toml | 2 +- doc/api/garage-admin-v2.json | 2 ++ src/api/admin/api.rs | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b7830a7d..ab35f757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,7 +101,7 @@ serde = { version = "1.0", default-features = false, features = ["derive", "rc"] serde_bytes = "0.11" serde_json = "1.0" toml = { version = "0.8", default-features = false, features = ["parse"] } -utoipa = "5.3.1" +utoipa = { version = "5.3.1", features = ["chrono"] } # newer version requires rust edition 2021 k8s-openapi = { version = "0.21", features = ["v1_24"] } diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 6ede967b..8f3517cb 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2053,6 +2053,7 @@ "string", "null" ], + "format": "date-time", "description": "Expiration time and date, formatted according to RFC 3339" }, "expired": { @@ -3639,6 +3640,7 @@ "string", "null" ], + "format": "date-time", "description": "Expiration time and date, formatted according to RFC 3339" }, "name": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 94cb7377..11ffb772 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -317,7 +317,6 @@ pub struct GetAdminTokenInfoResponse { /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 - #[schema(value_type = Option)] pub expiration: Option>, /// Whether this admin token is expired already pub expired: bool, @@ -357,7 +356,6 @@ pub struct UpdateAdminTokenRequestBody { /// Name of the admin API token pub name: Option, /// Expiration time and date, formatted according to RFC 3339 - #[schema(value_type = Option)] pub expiration: Option>, /// Scope of the admin API token, a list of admin endpoint names (such as /// `GetClusterStatus`, etc), or the special value `*` to allow all From eb40475f1ee8972a1210e750f8c4e8d210aecb9e Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 09:21:53 +0100 Subject: [PATCH 079/192] move bucket search logic from helper to admin api --- src/api/admin/bucket.rs | 53 +++++++++++++++++++++++++++++++++----- src/model/helper/bucket.rs | 50 ----------------------------------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 966546bb..7f89d4b2 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -82,15 +82,56 @@ impl RequestHandler for GetBucketInfoRequest { let bucket_id = match (self.id, self.global_alias, self.search) { (Some(id), None, None) => parse_bucket_id(&id)?, (None, Some(ga), None) => garage - .bucket_helper() - .resolve_global_bucket_name(&ga) + .bucket_alias_table + .get(&EmptyKey, &ga) .await? + .and_then(|x| *x.state.get()) .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, (None, None, Some(search)) => { - garage - .bucket_helper() - .admin_get_existing_matching_bucket(&search) - .await? + let helper = garage.bucket_helper(); + if let Some(uuid) = helper.resolve_global_bucket_name(&search).await? { + uuid + } else { + let hexdec = if search.len() >= 2 { + search + .get(..search.len() & !1) + .and_then(|x| hex::decode(x).ok()) + } else { + None + }; + let hex = hexdec + .ok_or_else(|| Error::Common(CommonError::NoSuchBucket(search.clone())))?; + + let mut start = [0u8; 32]; + start + .as_mut_slice() + .get_mut(..hex.len()) + .ok_or_bad_request("invalid length")? + .copy_from_slice(&hex); + let mut candidates = garage + .bucket_table + .get_range( + &EmptyKey, + Some(start.into()), + Some(DeletedFilter::NotDeleted), + 10, + EnumerationOrder::Forward, + ) + .await? + .into_iter() + .collect::>(); + candidates.retain(|x| hex::encode(x.id).starts_with(&search)); + if candidates.is_empty() { + return Err(Error::Common(CommonError::NoSuchBucket(search.clone()))); + } else if candidates.len() == 1 { + candidates.into_iter().next().unwrap().id + } else { + return Err(Error::bad_request(format!( + "Several matching buckets: {}", + search + ))); + } + } } _ => { return Err(Error::bad_request( diff --git a/src/model/helper/bucket.rs b/src/model/helper/bucket.rs index fe86c9d9..a712d683 100644 --- a/src/model/helper/bucket.rs +++ b/src/model/helper/bucket.rs @@ -67,56 +67,6 @@ impl<'a> BucketHelper<'a> { } } - /// Find a bucket by its global alias or a prefix of its uuid - pub async fn admin_get_existing_matching_bucket( - &self, - pattern: &String, - ) -> Result { - if let Some(uuid) = self.resolve_global_bucket_name(pattern).await? { - Ok(uuid) - } else { - let hexdec = if pattern.len() >= 2 { - pattern - .get(..pattern.len() & !1) - .and_then(|x| hex::decode(x).ok()) - } else { - None - }; - let hex = hexdec.ok_or_else(|| Error::NoSuchBucket(pattern.clone()))?; - - let mut start = [0u8; 32]; - start - .as_mut_slice() - .get_mut(..hex.len()) - .ok_or_bad_request("invalid length")? - .copy_from_slice(&hex); - let mut candidates = self - .0 - .bucket_table - .get_range( - &EmptyKey, - Some(start.into()), - Some(DeletedFilter::NotDeleted), - 10, - EnumerationOrder::Forward, - ) - .await? - .into_iter() - .collect::>(); - candidates.retain(|x| hex::encode(x.id).starts_with(pattern)); - if candidates.is_empty() { - Err(Error::NoSuchBucket(pattern.clone())) - } else if candidates.len() == 1 { - Ok(candidates.into_iter().next().unwrap().id) - } else { - Err(Error::BadRequest(format!( - "Several matching buckets: {}", - pattern - ))) - } - } - } - /// Returns a Bucket if it is present in bucket table, /// even if it is in deleted state. Querying a non-existing /// bucket ID returns an internal error. From 325f79012cd2f0cbc35c4c4185ecd927561c1928 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 09:29:54 +0100 Subject: [PATCH 080/192] admin_token_table: implement is_tombstone() --- src/model/admin_token_table.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index 45532e54..f3940299 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -139,6 +139,9 @@ impl Entry for AdminApiToken { fn sort_key(&self) -> &String { &self.prefix } + fn is_tombstone(&self) -> bool { + self.is_deleted() + } } pub struct AdminApiTokenTable; From 88b4623bf14f597cc19fb69d2f82e36e8046ca40 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 09:52:39 +0100 Subject: [PATCH 081/192] add creation date to admin api tokens --- doc/api/garage-admin-v2.json | 8 ++++++++ src/api/admin/admin_token.rs | 6 ++++++ src/api/admin/api.rs | 2 ++ src/garage/cli/remote/admin_token.rs | 24 ++++++++++++++++-------- src/model/admin_token_table.rs | 5 +++++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 8f3517cb..91d92e11 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2048,6 +2048,14 @@ "scope" ], "properties": { + "created": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Creation date" + }, "expiration": { "type": [ "string", diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index aca7a519..04bfdd96 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -41,6 +41,7 @@ impl RequestHandler for ListAdminTokensRequest { 0, GetAdminTokenInfoResponse { id: None, + created: None, name: "admin_token (from daemon configuration)".into(), expiration: None, expired: false, @@ -54,6 +55,7 @@ impl RequestHandler for ListAdminTokensRequest { 1, GetAdminTokenInfoResponse { id: None, + created: None, name: "metrics_token (from daemon configuration)".into(), expiration: None, expired: false, @@ -180,6 +182,10 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf GetAdminTokenInfoResponse { id: Some(token.prefix.clone()), + created: Some( + DateTime::from_timestamp_millis(params.created as i64) + .expect("invalid timestamp stored in db"), + ), name: params.name.get().to_string(), expiration: params.expiration.get().map(|x| { DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 11ffb772..fde304f4 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -314,6 +314,8 @@ pub struct GetAdminTokenInfoRequest { pub struct GetAdminTokenInfoResponse { /// Identifier of the admin token (which is also a prefix of the full bearer token) pub id: Option, + /// Creation date + pub created: Option>, /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 464480a1..4d765b92 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::Utc; +use chrono::{Local, Utc}; use garage_util::error::*; @@ -30,11 +30,15 @@ impl Cli { } pub async fn cmd_list_admin_tokens(&self) -> Result<(), Error> { - let list = self.api_request(ListAdminTokensRequest).await?; + let mut list = self.api_request(ListAdminTokensRequest).await?; - let mut table = vec!["ID\tNAME\tEXPIRATION\tSCOPE".to_string()]; + list.0.sort_by_key(|x| x.created); + + let mut table = vec!["ID\tCREATED\tNAME\tEXPIRATION\tSCOPE".to_string()]; for tok in list.0.iter() { - let scope = if tok.scope.len() > 1 { + let scope = if tok.expired { + String::new() + } else if tok.scope.len() > 1 { format!("[{}]", tok.scope.len()) } else { tok.scope.get(0).cloned().unwrap_or_default() @@ -43,12 +47,15 @@ impl Cli { "expired".to_string() } else { tok.expiration - .map(|x| x.to_string()) + .map(|x| x.with_timezone(&Local).to_string()) .unwrap_or("never".into()) }; table.push(format!( - "{}\t{}\t{}\t{}\t", + "{}\t{}\t{}\t{}\t{}", tok.id.as_deref().unwrap_or("-"), + tok.created + .map(|x| x.with_timezone(&Local).date_naive().to_string()) + .unwrap_or("-".into()), tok.name, exp, scope, @@ -209,8 +216,9 @@ impl Cli { fn print_token_info(token: &GetAdminTokenInfoResponse) { format_table(vec![ - format!("ID:\t{}", token.id.as_deref().unwrap_or("-")), + format!("ID:\t{}", token.id.as_ref().unwrap()), format!("Name:\t{}", token.name), + format!("Created:\t{}", token.created.unwrap().with_timezone(&Local)), format!( "Validity:\t{}", token.expired.then_some("EXPIRED").unwrap_or("valid") @@ -219,7 +227,7 @@ fn print_token_info(token: &GetAdminTokenInfoResponse) { "Expiration:\t{}", token .expiration - .map(|x| x.to_string()) + .map(|x| x.with_timezone(&Local).to_string()) .unwrap_or("never".into()) ), format!("Scope:\t{}", token.scope.to_vec().join(", ")), diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index f3940299..ef91eb4a 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -1,6 +1,7 @@ use base64::prelude::*; use garage_util::crdt::{self, Crdt}; +use garage_util::time::now_msec; use garage_table::{EmptyKey, Entry, TableSchema}; @@ -24,6 +25,9 @@ mod v2 { #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub struct AdminApiTokenParams { + /// Creation date + pub created: u64, + /// The entire API token hashed as a password pub token_hash: String, @@ -91,6 +95,7 @@ impl AdminApiToken { let ret = AdminApiToken { prefix, state: crdt::Deletable::present(AdminApiTokenParams { + created: now_msec(), token_hash: hashed_token, name: crdt::Lww::new(name.to_string()), expiration: crdt::Lww::new(None), From d2a064bb1b9ad01a20e9fba7842b343916da665a Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 10:15:12 +0100 Subject: [PATCH 082/192] cli: add and remove scopes using --scope=+Scope or --scope=-Scope --- src/garage/cli/remote/admin_token.rs | 26 ++++++++++++++++++++++---- src/garage/cli/structs.rs | 16 ++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 4d765b92..78286dc4 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -152,10 +152,28 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), - scope: opt.scope.map(|s| { - s.split(",") - .map(|x| x.trim().to_string()) - .collect::>() + scope: opt.scope.map({ + let mut new_scope = token.scope; + |scope_str| { + if let Some(add) = scope_str.strip_prefix("+") { + for a in add.split(",").map(|x| x.trim().to_string()) { + if !new_scope.contains(&a) { + new_scope.push(a); + } + } + new_scope + } else if let Some(sub) = scope_str.strip_prefix("-") { + for r in sub.split(",").map(|x| x.trim()) { + new_scope.retain(|x| x != r); + } + new_scope + } else { + scope_str + .split(",") + .map(|x| x.trim().to_string()) + .collect::>() + } + } }), }, }) diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 0b0a8b94..d4446a17 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -528,7 +528,12 @@ pub struct AdminTokenCreateOp { /// format) #[structopt(long = "expires-in")] pub expires_in: Option, - /// Set a limited scope for the token (by default, `*`) + /// Set a limited scope for the token, as a comma-separated list of + /// admin API functions (e.g. GetClusterStatus, etc.). The default scope + /// is `*`, which allows access to all admin API functions. + /// Note that granting a scope that allows `CreateAdminToken` or + /// `UpdateAdminToken` allows for privilege escalation, and is therefore + /// equivalent to `*`. #[structopt(long = "scope")] pub scope: Option, /// Print only the newly generated API token to stdout @@ -544,7 +549,14 @@ pub struct AdminTokenSetOp { /// format) #[structopt(long = "expires-in")] pub expires_in: Option, - /// Set a limited scope for the token + /// Set a limited scope for the token, as a comma-separated list of + /// admin API functions (e.g. GetClusterStatus, etc.), or `*` to allow + /// all admin API functions. + /// Use `--scope=+Scope1,Scope2` to add scopes to the existing list, + /// and `--scope=-Scope1,Scope2` to remove scopes from the existing list. + /// Note that granting a scope that allows `CreateAdminToken` or + /// `UpdateAdminToken` allows for privilege escalation, and is therefore + /// equivalent to `*`. #[structopt(long = "scope")] pub scope: Option, } From 795b4a41b72dcc786849e5f6bf69a24eea114ca3 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 10:52:58 +0100 Subject: [PATCH 083/192] admin api: add special endpoints to openapi spec --- doc/api/garage-admin-v2.json | 68 ++++++++++++++++++++++++++++++++++++ src/api/admin/openapi.rs | 57 ++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 91d92e11..9379fee5 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -21,6 +21,74 @@ } ], "paths": { + "/check": { + "get": { + "tags": [ + "Special endpoints" + ], + "description": "\nStatic website domain name check. Checks whether a bucket is configured to serve\na static website for the requested domain. This is used by reverse proxies such\nas Caddy or Tricot, to avoid requesting TLS certificates for domain names that\ndo not correspond to an actual website.\n ", + "operationId": "CheckDomain", + "parameters": [ + { + "name": "domain", + "in": "path", + "description": "The domain name to check for", + "required": true + } + ], + "responses": { + "200": { + "description": "The domain name redirects to a static website bucket" + }, + "400": { + "description": "No static website bucket exists for this domain" + } + }, + "security": [ + {} + ] + } + }, + "/health": { + "get": { + "tags": [ + "Special endpoints" + ], + "description": "\nCheck cluster health. The status code returned by this function indicates\nwhether this Garage daemon can answer API requests.\nGarage will return `200 OK` even if some storage nodes are disconnected,\nas long as it is able to have a quorum of nodes for read and write operations.\n ", + "operationId": "Health", + "responses": { + "200": { + "description": "Garage is able to answer requests" + }, + "503": { + "description": "This Garage daemon is not able to handle requests" + } + }, + "security": [ + {} + ] + } + }, + "/metrics": { + "get": { + "tags": [ + "Special endpoints" + ], + "description": "Prometheus metrics endpoint", + "operationId": "Metrics", + "responses": { + "200": { + "description": "Garage daemon metrics exported in Prometheus format" + } + }, + "security": [ + {}, + { + "bearerAuth": [] + } + ] + } + }, "/v2/AddBucketAlias": { "post": { "tags": [ diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 24319817..77d8dce8 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -5,6 +5,59 @@ use utoipa::{Modify, OpenApi}; use crate::api::*; +// ********************************************** +// Special endpoints +// ********************************************** + +#[utoipa::path(get, + path = "/metrics", + tag = "Special endpoints", + description = "Prometheus metrics endpoint", + security((), ("bearerAuth" = [])), + responses( + (status = 200, description = "Garage daemon metrics exported in Prometheus format"), + ), +)] +fn Metrics() -> () {} + +#[utoipa::path(get, + path = "/health", + tag = "Special endpoints", + description = " +Check cluster health. The status code returned by this function indicates +whether this Garage daemon can answer API requests. +Garage will return `200 OK` even if some storage nodes are disconnected, +as long as it is able to have a quorum of nodes for read and write operations. + ", + security(()), + responses( + (status = 200, description = "Garage is able to answer requests"), + (status = 503, description = "This Garage daemon is not able to handle requests") + ), +)] +fn Health() -> () {} + +#[utoipa::path(get, + path = "/check", + tag = "Special endpoints", + description = " +Static website domain name check. Checks whether a bucket is configured to serve +a static website for the requested domain. This is used by reverse proxies such +as Caddy or Tricot, to avoid requesting TLS certificates for domain names that +do not correspond to an actual website. + ", + params( + ("domain", description = "The domain name to check for"), + ), + security(()), + responses( + (status = 200, description = "The domain name redirects to a static website bucket"), + (status = 400, description = "No static website bucket exists for this domain") + ), +)] +fn CheckDomain() -> () {} + + // ********************************************** // Cluster operations // ********************************************** @@ -794,6 +847,10 @@ impl Modify for SecurityAddon { modifiers(&SecurityAddon), security(("bearerAuth" = [])), paths( + // Special ops + Metrics, + Health, + CheckDomain, // Cluster operations GetClusterHealth, GetClusterStatus, From 0b12debf6c359070802816f6ca5264dfd02e231d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 11:07:12 +0100 Subject: [PATCH 084/192] admin api: generate params from struct --- doc/api/garage-admin-v2.json | 61 +++++++++++++++++++++++++++++++----- src/api/admin/api.rs | 20 +++++++++--- src/api/admin/openapi.rs | 18 ++--------- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 9379fee5..fbb1f6c5 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -554,13 +554,25 @@ "name": "id", "in": "path", "description": "Admin API token ID", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } }, { "name": "search", "in": "path", "description": "Partial token ID or name to search for", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { @@ -634,19 +646,37 @@ "name": "id", "in": "path", "description": "Exact bucket ID to look up", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } }, { "name": "globalAlias", "in": "path", "description": "Global alias of bucket to look up", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } }, { "name": "search", "in": "path", "description": "Partial ID or alias to search for", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { @@ -795,19 +825,34 @@ "name": "id", "in": "path", "description": "Access key ID", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } }, { "name": "search", "in": "path", "description": "Partial key ID or name to search for", - "required": true + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } }, { "name": "showSecretKey", "in": "path", "description": "Whether to return the secret access key", - "required": true + "required": true, + "schema": { + "type": "boolean" + } } ], "responses": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index fde304f4..3694fd67 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use paste::paste; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use garage_rpc::*; @@ -303,9 +303,12 @@ pub struct ListAdminTokensResponse(pub Vec); // ---- GetAdminTokenInfo ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] pub struct GetAdminTokenInfoRequest { + /// Admin API token ID pub id: Option, + /// Partial token ID or name to search for pub search: Option, } @@ -634,10 +637,15 @@ pub struct ListKeysResponseItem { // ---- GetKeyInfo ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] pub struct GetKeyInfoRequest { + /// Access key ID pub id: Option, + /// Partial key ID or name to search for pub search: Option, + /// Whether to return the secret access key + #[serde(default)] pub show_secret_key: bool, } @@ -761,10 +769,14 @@ pub struct BucketLocalAlias { // ---- GetBucketInfo ---- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] pub struct GetBucketInfoRequest { + /// Exact bucket ID to look up pub id: Option, + /// Global alias of bucket to look up pub global_alias: Option, + /// Partial ID or alias to search for pub search: Option, } diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 77d8dce8..b7ffdcf1 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -57,7 +57,6 @@ do not correspond to an actual website. )] fn CheckDomain() -> () {} - // ********************************************** // Cluster operations // ********************************************** @@ -141,10 +140,7 @@ fn ListAdminTokens() -> () {} Return information about a specific admin API token. You can search by specifying the exact token identifier (`id`) or by specifying a pattern (`search`). ", - params( - ("id", description = "Admin API token ID"), - ("search", description = "Partial token ID or name to search for"), - ), + params(GetAdminTokenInfoRequest), responses( (status = 200, description = "Information about the admin token", body = GetAdminTokenInfoResponse), (status = 500, description = "Internal server error") @@ -337,11 +333,7 @@ You can search by specifying the exact key identifier (`id`) or by specifying a For confidentiality reasons, the secret key is not returned by default: you must pass the `showSecretKey` query parameter to get it. ", - params( - ("id", description = "Access key ID"), - ("search", description = "Partial key ID or name to search for"), - ("showSecretKey", description = "Whether to return the secret access key"), - ), + params(GetKeyInfoRequest), responses( (status = 200, description = "Information about the access key", body = GetKeyInfoResponse), (status = 500, description = "Internal server error") @@ -434,11 +426,7 @@ It includes its aliases, its web configuration, keys that have some permissions on it, some statistics (number of objects, size), number of dangling multipart uploads, and its quotas (if any). ", - params( - ("id", description = "Exact bucket ID to look up"), - ("globalAlias", description = "Global alias of bucket to look up"), - ("search", description = "Partial ID or alias to search for"), - ), + params(GetBucketInfoRequest), responses( (status = 200, description = "Returns exhaustive information about the bucket", body = GetBucketInfoResponse), (status = 500, description = "Internal server error") From e6862c5d3dc15bbf8c7b8213c434758716c13d8b Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 15:01:39 +0100 Subject: [PATCH 085/192] cli: uniformize output and add some infos --- doc/api/garage-admin-v2.json | 17 +- src/api/admin/Cargo.toml | 1 + src/api/admin/api.rs | 9 +- src/api/admin/block.rs | 6 +- src/api/admin/cluster.rs | 13 +- src/api/admin/node.rs | 79 +++--- src/garage/Cargo.toml | 2 +- src/garage/cli/local/init.rs | 10 - src/garage/cli/remote/admin_token.rs | 22 +- src/garage/cli/remote/block.rs | 42 ++- src/garage/cli/remote/bucket.rs | 379 ++++++++++++--------------- src/garage/cli/remote/cluster.rs | 8 +- src/garage/cli/remote/key.rs | 57 ++-- src/garage/cli/remote/layout.rs | 6 +- src/garage/cli/remote/mod.rs | 12 + src/garage/cli/remote/node.rs | 25 +- src/rpc/layout/version.rs | 2 +- src/rpc/system.rs | 9 + src/table/data.rs | 5 + src/table/metrics.rs | 16 ++ 20 files changed, 391 insertions(+), 329 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index fbb1f6c5..31aaa915 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1752,7 +1752,8 @@ "type": "object", "required": [ "versionId", - "deleted", + "refDeleted", + "versionDeleted", "garbageCollected" ], "properties": { @@ -1766,10 +1767,13 @@ } ] }, - "deleted": { + "garbageCollected": { "type": "boolean" }, - "garbageCollected": { + "refDeleted": { + "type": "boolean" + }, + "versionDeleted": { "type": "boolean" }, "versionId": { @@ -3516,6 +3520,13 @@ "type": "boolean", "description": "Whether this node is part of an older layout version and is draining data." }, + "garageVersion": { + "type": [ + "string", + "null" + ], + "description": "Garage version" + }, "hostname": { "type": [ "string", diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 65d9fda9..92d041cc 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -47,3 +47,4 @@ prometheus = { workspace = true, optional = true } [features] metrics = [ "opentelemetry-prometheus", "prometheus" ] +k2v = [ "garage_model/k2v" ] diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 3694fd67..b865ac88 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -188,8 +188,8 @@ pub struct GetClusterStatusResponse { pub struct NodeResp { /// Full-length node identifier pub id: String, - /// Role assigned to this node in the current cluster layout - pub role: Option, + /// Garage version + pub garage_version: Option, /// Socket address used by other nodes to connect to this node for RPC #[schema(value_type = Option)] pub addr: Option, @@ -200,6 +200,8 @@ pub struct NodeResp { /// For disconnected nodes, the number of seconds since last contact, /// or `null` if no contact was established since Garage restarted. pub last_seen_secs_ago: Option, + /// Role assigned to this node in the current cluster layout + pub role: Option, /// Whether this node is part of an older layout version and is draining data. pub draining: bool, /// Total and available space on the disk partition(s) containing the data @@ -1174,7 +1176,8 @@ pub struct LocalGetBlockInfoResponse { #[serde(rename_all = "camelCase")] pub struct BlockVersion { pub version_id: String, - pub deleted: bool, + pub ref_deleted: bool, + pub version_deleted: bool, pub garbage_collected: bool, pub backlink: Option, } diff --git a/src/api/admin/block.rs b/src/api/admin/block.rs index 73d186a6..4b8edc63 100644 --- a/src/api/admin/block.rs +++ b/src/api/admin/block.rs @@ -84,14 +84,16 @@ impl RequestHandler for LocalGetBlockInfoRequest { }; versions.push(BlockVersion { version_id: hex::encode(&br.version), - deleted: v.deleted.get(), + ref_deleted: br.deleted.get(), + version_deleted: v.deleted.get(), garbage_collected: false, backlink: Some(bl), }); } else { versions.push(BlockVersion { version_id: hex::encode(&br.version), - deleted: true, + ref_deleted: br.deleted.get(), + version_deleted: true, garbage_collected: true, backlink: None, }); diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs index 6a555d04..09f59d63 100644 --- a/src/api/admin/cluster.rs +++ b/src/api/admin/cluster.rs @@ -33,6 +33,7 @@ impl RequestHandler for GetClusterStatusRequest { i.id, NodeResp { id: hex::encode(i.id), + garage_version: i.status.garage_version, addr: i.addr, hostname: i.status.hostname, is_up: i.is_up, @@ -231,12 +232,16 @@ impl RequestHandler for GetClusterStatisticsRequest { if meta_part_avail.len() < node_partition_count.len() || data_part_avail.len() < node_partition_count.len() { - writeln!(&mut ret, " data: < {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: < {}", meta_avail).unwrap(); + ret += &format_table_to_string(vec![ + format!(" data: < {}", data_avail), + format!(" metadata: < {}", meta_avail), + ]); writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap(); } else { - writeln!(&mut ret, " data: {}", data_avail).unwrap(); - writeln!(&mut ret, " metadata: {}", meta_avail).unwrap(); + ret += &format_table_to_string(vec![ + format!(" data: {}", data_avail), + format!(" metadata: {}", meta_avail), + ]); } } diff --git a/src/api/admin/node.rs b/src/api/admin/node.rs index 9994cfd0..fcc7e4d3 100644 --- a/src/api/admin/node.rs +++ b/src/api/admin/node.rs @@ -55,27 +55,48 @@ impl RequestHandler for LocalGetNodeStatisticsRequest { garage: &Arc, _admin: &Admin, ) -> Result { - let mut ret = String::new(); - writeln!( - &mut ret, - "Garage version: {} [features: {}]\nRust compiler version: {}", - garage_util::version::garage_version(), - garage_util::version::garage_features() - .map(|list| list.join(", ")) - .unwrap_or_else(|| "(unknown)".into()), - garage_util::version::rust_version(), - ) - .unwrap(); + let sys_status = garage.system.local_status(); - writeln!(&mut ret, "\nDatabase engine: {}", garage.db.engine()).unwrap(); + let mut ret = format_table_to_string(vec![ + format!("Node ID:\t{:?}", garage.system.id), + format!("Hostname:\t{}", sys_status.hostname.unwrap_or_default(),), + format!( + "Garage version:\t{}", + garage_util::version::garage_version(), + ), + format!( + "Garage features:\t{}", + garage_util::version::garage_features() + .map(|list| list.join(", ")) + .unwrap_or_else(|| "(unknown)".into()), + ), + format!( + "Rust compiler version:\t{}", + garage_util::version::rust_version(), + ), + format!("Database engine:\t{}", garage.db.engine()), + ]); // Gather table statistics - let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tGcTodo".into()]; + let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tInsQueue\tGcTodo".into()]; + table.push(gather_table_stats(&garage.admin_token_table)?); table.push(gather_table_stats(&garage.bucket_table)?); + table.push(gather_table_stats(&garage.bucket_alias_table)?); table.push(gather_table_stats(&garage.key_table)?); + table.push(gather_table_stats(&garage.object_table)?); + table.push(gather_table_stats(&garage.object_counter_table.table)?); + table.push(gather_table_stats(&garage.mpu_table)?); + table.push(gather_table_stats(&garage.mpu_counter_table.table)?); table.push(gather_table_stats(&garage.version_table)?); table.push(gather_table_stats(&garage.block_ref_table)?); + + #[cfg(feature = "k2v")] + { + table.push(gather_table_stats(&garage.k2v.item_table)?); + table.push(gather_table_stats(&garage.k2v.counter_table.table)?); + } + write!( &mut ret, "\nTable stats:\n{}", @@ -87,24 +108,17 @@ impl RequestHandler for LocalGetNodeStatisticsRequest { writeln!(&mut ret, "\nBlock manager stats:").unwrap(); let rc_len = garage.block_manager.rc_len()?.to_string(); - writeln!( - &mut ret, - " number of RC entries (~= number of blocks): {}", - rc_len - ) - .unwrap(); - writeln!( - &mut ret, - " resync queue length: {}", - garage.block_manager.resync.queue_len()? - ) - .unwrap(); - writeln!( - &mut ret, - " blocks with resync errors: {}", - garage.block_manager.resync.errors_len()? - ) - .unwrap(); + ret += &format_table_to_string(vec![ + format!(" number of RC entries:\t{} (~= number of blocks)", rc_len), + format!( + " resync queue length:\t{}", + garage.block_manager.resync.queue_len()? + ), + format!( + " blocks with resync errors:\t{}", + garage.block_manager.resync.errors_len()? + ), + ]); Ok(LocalGetNodeStatisticsResponse { freeform: ret }) } @@ -119,11 +133,12 @@ where let mkl_len = t.merkle_updater.merkle_tree_len()?.to_string(); Ok(format!( - " {}\t{}\t{}\t{}\t{}", + " {}\t{}\t{}\t{}\t{}\t{}", F::TABLE_NAME, data_len, mkl_len, t.merkle_updater.todo_len()?, + t.data.insert_queue_len()?, t.data.gc_todo_len()? )) } diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index 045a6174..2ce4fe52 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -85,7 +85,7 @@ k2v-client.workspace = true [features] default = [ "bundled-libs", "metrics", "lmdb", "sqlite", "k2v" ] -k2v = [ "garage_util/k2v", "garage_api_k2v" ] +k2v = [ "garage_util/k2v", "garage_api_k2v", "garage_api_admin/k2v" ] # Database engines lmdb = [ "garage_model/lmdb" ] diff --git a/src/garage/cli/local/init.rs b/src/garage/cli/local/init.rs index 43ca5c09..683930ca 100644 --- a/src/garage/cli/local/init.rs +++ b/src/garage/cli/local/init.rs @@ -36,16 +36,6 @@ pub fn node_id_command(config_file: PathBuf, quiet: bool) -> Result<(), Error> { ); eprintln!(" garage [-c ] node connect {}", idstr); eprintln!(); - eprintln!("Or instruct them to connect from here by running:"); - eprintln!( - " garage -c {} -h node connect {}", - config_file.to_string_lossy(), - idstr - ); - eprintln!( - "where is their own node identifier in the format: @:" - ); - eprintln!(); eprintln!("This node identifier can also be added as a bootstrap node in other node's garage.toml files:"); eprintln!(" bootstrap_peers = ["); eprintln!(" \"{}\",", idstr); diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 78286dc4..09699ad7 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -34,14 +34,12 @@ impl Cli { list.0.sort_by_key(|x| x.created); - let mut table = vec!["ID\tCREATED\tNAME\tEXPIRATION\tSCOPE".to_string()]; + let mut table = vec!["ID\tCreated\tName\tExpiration\tScope".to_string()]; for tok in list.0.iter() { let scope = if tok.expired { String::new() - } else if tok.scope.len() > 1 { - format!("[{}]", tok.scope.len()) } else { - tok.scope.get(0).cloned().unwrap_or_default() + table_list_abbr(&tok.scope) }; let exp = if tok.expired { "expired".to_string() @@ -233,7 +231,7 @@ impl Cli { } fn print_token_info(token: &GetAdminTokenInfoResponse) { - format_table(vec![ + let mut table = vec![ format!("ID:\t{}", token.id.as_ref().unwrap()), format!("Name:\t{}", token.name), format!("Created:\t{}", token.created.unwrap().with_timezone(&Local)), @@ -248,6 +246,16 @@ fn print_token_info(token: &GetAdminTokenInfoResponse) { .map(|x| x.with_timezone(&Local).to_string()) .unwrap_or("never".into()) ), - format!("Scope:\t{}", token.scope.to_vec().join(", ")), - ]); + String::new(), + ]; + + for (i, scope) in token.scope.iter().enumerate() { + if i == 0 { + table.push(format!("Scope:\t{}", scope)); + } else { + table.push(format!("\t{}", scope)); + } + } + + format_table(table); } diff --git a/src/garage/cli/remote/block.rs b/src/garage/cli/remote/block.rs index 933dcbdb..f70decd7 100644 --- a/src/garage/cli/remote/block.rs +++ b/src/garage/cli/remote/block.rs @@ -51,46 +51,70 @@ impl Cli { .local_api_request(LocalGetBlockInfoRequest { block_hash: hash }) .await?; - println!("Block hash: {}", info.block_hash); - println!("Refcount: {}", info.refcount); + println!("==== BLOCK INFORMATION ===="); + format_table(vec![ + format!("Block hash:\t{}", info.block_hash), + format!("Refcount:\t{}", info.refcount), + ]); println!(); - let mut table = vec!["Version\tBucket\tKey\tMPU\tDeleted".into()]; + println!("==== REFERENCES TO THIS BLOCK ===="); + let mut table = vec!["Status\tVersion\tBucket\tKey\tMPU".into()]; let mut nondeleted_count = 0; + let mut inconsistent_refs = false; for ver in info.versions.iter() { match &ver.backlink { Some(BlockVersionBacklink::Object { bucket_id, key }) => { table.push(format!( - "{:.16}\t{:.16}\t{}\t\t{:?}", - ver.version_id, bucket_id, key, ver.deleted + "{}\t{:.16}{}\t{:.16}\t{}", + ver.ref_deleted.then_some("deleted").unwrap_or("active"), + ver.version_id, + ver.version_deleted + .then_some(" (deleted)") + .unwrap_or_default(), + bucket_id, + key )); } Some(BlockVersionBacklink::Upload { upload_id, - upload_deleted: _, + upload_deleted, upload_garbage_collected: _, bucket_id, key, }) => { table.push(format!( - "{:.16}\t{:.16}\t{}\t{:.16}\t{:.16}", + "{}\t{:.16}{}\t{:.16}\t{}\t{:.16}{}", + ver.ref_deleted.then_some("deleted").unwrap_or("active"), ver.version_id, + ver.version_deleted + .then_some(" (deleted)") + .unwrap_or_default(), bucket_id.as_deref().unwrap_or(""), key.as_deref().unwrap_or(""), upload_id, - ver.deleted + upload_deleted.then_some(" (deleted)").unwrap_or_default(), )); } None => { table.push(format!("{:.16}\t\t\tyes", ver.version_id)); } } - if !ver.deleted { + if ver.ref_deleted != ver.version_deleted { + inconsistent_refs = true; + } + if !ver.ref_deleted { nondeleted_count += 1; } } format_table(table); + if inconsistent_refs { + println!(); + println!("There are inconsistencies between the block_ref and the version tables."); + println!("Fix them by running `garage repair block-refs`"); + } + if info.refcount != nondeleted_count { println!(); println!( diff --git a/src/garage/cli/remote/bucket.rs b/src/garage/cli/remote/bucket.rs index 9adcdbe5..09e3de64 100644 --- a/src/garage/cli/remote/bucket.rs +++ b/src/garage/cli/remote/bucket.rs @@ -30,21 +30,18 @@ impl Cli { pub async fn cmd_list_buckets(&self) -> Result<(), Error> { let buckets = self.api_request(ListBucketsRequest).await?; - println!("List of buckets:"); - - let mut table = vec![]; + let mut table = vec!["ID\tGlobal aliases\tLocal aliases".to_string()]; for bucket in buckets.0.iter() { - let local_aliases_n = match &bucket.local_aliases[..] { - [] => "".into(), - [alias] => format!("{}:{}", alias.access_key_id, alias.alias), - s => format!("[{} local aliases]", s.len()), - }; - table.push(format!( - "\t{}\t{}\t{}", - bucket.global_aliases.join(","), - local_aliases_n, + "{:.16}\t{}\t{}", bucket.id, + table_list_abbr(&bucket.global_aliases), + table_list_abbr( + bucket + .local_aliases + .iter() + .map(|x| format!("{}:{}", x.access_key_id, x.alias)) + ), )); } format_table(table); @@ -61,88 +58,20 @@ impl Cli { }) .await?; - println!("Bucket: {}", bucket.id); - - let size = bytesize::ByteSize::b(bucket.bytes as u64); - println!( - "\nSize: {} ({})", - size.to_string_as(true), - size.to_string_as(false) - ); - println!("Objects: {}", bucket.objects); - println!( - "Unfinished uploads (multipart and non-multipart): {}", - bucket.unfinished_uploads, - ); - println!( - "Unfinished multipart uploads: {}", - bucket.unfinished_multipart_uploads - ); - let mpu_size = bytesize::ByteSize::b(bucket.unfinished_multipart_uploads as u64); - println!( - "Size of unfinished multipart uploads: {} ({})", - mpu_size.to_string_as(true), - mpu_size.to_string_as(false), - ); - - println!("\nWebsite access: {}", bucket.website_access); - - if bucket.quotas.max_size.is_some() || bucket.quotas.max_objects.is_some() { - println!("\nQuotas:"); - if let Some(ms) = bucket.quotas.max_size { - let ms = bytesize::ByteSize::b(ms); - println!( - " maximum size: {} ({})", - ms.to_string_as(true), - ms.to_string_as(false) - ); - } - if let Some(mo) = bucket.quotas.max_objects { - println!(" maximum number of objects: {}", mo); - } - } - - println!("\nGlobal aliases:"); - for alias in bucket.global_aliases { - println!(" {}", alias); - } - - println!("\nKey-specific aliases:"); - let mut table = vec![]; - for key in bucket.keys.iter() { - for alias in key.bucket_local_aliases.iter() { - table.push(format!("\t{} ({})\t{}", key.access_key_id, key.name, alias)); - } - } - format_table(table); - - println!("\nAuthorized keys:"); - let mut table = vec![]; - for key in bucket.keys.iter() { - if !(key.permissions.read || key.permissions.write || key.permissions.owner) { - continue; - } - let rflag = if key.permissions.read { "R" } else { " " }; - let wflag = if key.permissions.write { "W" } else { " " }; - let oflag = if key.permissions.owner { "O" } else { " " }; - table.push(format!( - "\t{}{}{}\t{}\t{}", - rflag, wflag, oflag, key.access_key_id, key.name - )); - } - format_table(table); + print_bucket_info(&bucket); Ok(()) } pub async fn cmd_create_bucket(&self, opt: BucketOpt) -> Result<(), Error> { - self.api_request(CreateBucketRequest { - global_alias: Some(opt.name.clone()), - local_alias: None, - }) - .await?; + let bucket = self + .api_request(CreateBucketRequest { + global_alias: Some(opt.name.clone()), + local_alias: None, + }) + .await?; - println!("Bucket {} was created.", opt.name); + print_bucket_info(&bucket.0); Ok(()) } @@ -200,7 +129,7 @@ impl Cli { }) .await?; - if let Some(key_pat) = &opt.local { + let res = if let Some(key_pat) = &opt.local { let key = self .api_request(GetKeyInfoRequest { search: Some(key_pat.clone()), @@ -216,12 +145,7 @@ impl Cli { access_key_id: key.access_key_id.clone(), }, }) - .await?; - - println!( - "Alias {} now points to bucket {:.16} in namespace of key {}", - opt.new_name, bucket.id, key.access_key_id - ) + .await? } else { self.api_request(AddBucketAliasRequest { bucket_id: bucket.id.clone(), @@ -229,19 +153,16 @@ impl Cli { global_alias: opt.new_name.clone(), }, }) - .await?; + .await? + }; - println!( - "Alias {} now points to bucket {:.16}", - opt.new_name, bucket.id - ) - } + print_bucket_info(&res.0); Ok(()) } pub async fn cmd_unalias_bucket(&self, opt: UnaliasBucketOpt) -> Result<(), Error> { - if let Some(key_pat) = &opt.local { + let res = if let Some(key_pat) = &opt.local { let key = self .api_request(GetKeyInfoRequest { search: Some(key_pat.clone()), @@ -266,12 +187,7 @@ impl Cli { local_alias: opt.name.clone(), }, }) - .await?; - - println!( - "Alias {} no longer points to bucket {:.16} in namespace of key {}", - &opt.name, bucket.id, key.access_key_id - ) + .await? } else { let bucket = self .api_request(GetBucketInfoRequest { @@ -287,13 +203,10 @@ impl Cli { global_alias: opt.name.clone(), }, }) - .await?; + .await? + }; - println!( - "Alias {} no longer points to bucket {:.16}", - opt.name, bucket.id - ) - } + print_bucket_info(&res.0); Ok(()) } @@ -315,44 +228,19 @@ impl Cli { }) .await?; - self.api_request(AllowBucketKeyRequest(BucketKeyPermChangeRequest { - bucket_id: bucket.id.clone(), - access_key_id: key.access_key_id.clone(), - permissions: ApiBucketKeyPerm { - read: opt.read, - write: opt.write, - owner: opt.owner, - }, - })) - .await?; - - let new_bucket = self - .api_request(GetBucketInfoRequest { - id: Some(bucket.id), - global_alias: None, - search: None, - }) + let res = self + .api_request(AllowBucketKeyRequest(BucketKeyPermChangeRequest { + bucket_id: bucket.id.clone(), + access_key_id: key.access_key_id.clone(), + permissions: ApiBucketKeyPerm { + read: opt.read, + write: opt.write, + owner: opt.owner, + }, + })) .await?; - if let Some(new_key) = new_bucket - .keys - .iter() - .find(|k| k.access_key_id == key.access_key_id) - { - println!( - "New permissions for key {} on bucket {:.16}:\n read {}\n write {}\n owner {}", - key.access_key_id, - new_bucket.id, - new_key.permissions.read, - new_key.permissions.write, - new_key.permissions.owner - ); - } else { - println!( - "Access key {} has no permissions on bucket {:.16}", - key.access_key_id, new_bucket.id - ); - } + print_bucket_info(&res.0); Ok(()) } @@ -374,44 +262,19 @@ impl Cli { }) .await?; - self.api_request(DenyBucketKeyRequest(BucketKeyPermChangeRequest { - bucket_id: bucket.id.clone(), - access_key_id: key.access_key_id.clone(), - permissions: ApiBucketKeyPerm { - read: opt.read, - write: opt.write, - owner: opt.owner, - }, - })) - .await?; - - let new_bucket = self - .api_request(GetBucketInfoRequest { - id: Some(bucket.id), - global_alias: None, - search: None, - }) + let res = self + .api_request(DenyBucketKeyRequest(BucketKeyPermChangeRequest { + bucket_id: bucket.id.clone(), + access_key_id: key.access_key_id.clone(), + permissions: ApiBucketKeyPerm { + read: opt.read, + write: opt.write, + owner: opt.owner, + }, + })) .await?; - if let Some(new_key) = new_bucket - .keys - .iter() - .find(|k| k.access_key_id == key.access_key_id) - { - println!( - "New permissions for key {} on bucket {:.16}:\n read {}\n write {}\n owner {}", - key.access_key_id, - new_bucket.id, - new_key.permissions.read, - new_key.permissions.write, - new_key.permissions.owner - ); - } else { - println!( - "Access key {} no longer has permissions on bucket {:.16}", - key.access_key_id, new_bucket.id - ); - } + print_bucket_info(&res.0); Ok(()) } @@ -447,20 +310,17 @@ impl Cli { } }; - self.api_request(UpdateBucketRequest { - id: bucket.id, - body: UpdateBucketRequestBody { - website_access: Some(wa), - quotas: None, - }, - }) - .await?; + let res = self + .api_request(UpdateBucketRequest { + id: bucket.id, + body: UpdateBucketRequestBody { + website_access: Some(wa), + quotas: None, + }, + }) + .await?; - if opt.allow { - println!("Website access allowed for {}", &opt.bucket); - } else { - println!("Website access denied for {}", &opt.bucket); - } + print_bucket_info(&res.0); Ok(()) } @@ -500,16 +360,17 @@ impl Cli { }, }; - self.api_request(UpdateBucketRequest { - id: bucket.id.clone(), - body: UpdateBucketRequestBody { - website_access: None, - quotas: Some(new_quotas), - }, - }) - .await?; + let res = self + .api_request(UpdateBucketRequest { + id: bucket.id.clone(), + body: UpdateBucketRequestBody { + website_access: None, + quotas: Some(new_quotas), + }, + }) + .await?; - println!("Quotas updated for bucket {:.16}", bucket.id); + print_bucket_info(&res.0); Ok(()) } @@ -547,3 +408,105 @@ impl Cli { Ok(()) } } + +fn print_bucket_info(bucket: &GetBucketInfoResponse) { + println!("==== BUCKET INFORMATION ===="); + + let mut info = vec![ + format!("Bucket:\t{}", bucket.id), + String::new(), + { + let size = bytesize::ByteSize::b(bucket.bytes as u64); + format!( + "Size:\t{} ({})", + size.to_string_as(true), + size.to_string_as(false) + ) + }, + format!("Objects:\t{}", bucket.objects), + ]; + + if bucket.unfinished_uploads > 0 { + info.extend([ + format!( + "Unfinished uploads:\t{} multipart uploads", + bucket.unfinished_multipart_uploads + ), + format!("\t{} including regular uploads", bucket.unfinished_uploads), + { + let mpu_size = + bytesize::ByteSize::b(bucket.unfinished_multipart_upload_bytes as u64); + format!( + "Size of unfinished multipart uploads:\t{} ({})", + mpu_size.to_string_as(true), + mpu_size.to_string_as(false), + ) + }, + ]); + } + + info.extend([ + String::new(), + format!("Website access:\t{}", bucket.website_access), + ]); + + if let Some(wc) = &bucket.website_config { + info.extend([ + format!(" index document:\t{}", wc.index_document), + format!( + " error document:\t{}", + wc.error_document.as_deref().unwrap_or("(not defined)") + ), + ]); + } + + if bucket.quotas.max_size.is_some() || bucket.quotas.max_objects.is_some() { + info.push(String::new()); + info.push("Quotas:\tenabled".into()); + if let Some(ms) = bucket.quotas.max_size { + let ms = bytesize::ByteSize::b(ms); + info.push(format!( + " maximum size:\t{} ({})", + ms.to_string_as(true), + ms.to_string_as(false) + )); + } + if let Some(mo) = bucket.quotas.max_objects { + info.push(format!(" maximum number of objects:\t{}", mo)); + } + } + + if !bucket.global_aliases.is_empty() { + info.push(String::new()); + for (i, alias) in bucket.global_aliases.iter().enumerate() { + if i == 0 && bucket.global_aliases.len() > 1 { + info.push(format!("Global aliases:\t{}", alias)); + } else if i == 0 { + info.push(format!("Global alias:\t{}", alias)); + } else { + info.push(format!("\t{}", alias)); + } + } + } + + format_table(info); + + println!(""); + println!("==== KEYS FOR THIS BUCKET ===="); + let mut key_info = vec!["Permissions\tAccess key\t\tLocal aliases".to_string()]; + key_info.extend(bucket.keys.iter().map(|key| { + let rflag = if key.permissions.read { "R" } else { " " }; + let wflag = if key.permissions.write { "W" } else { " " }; + let oflag = if key.permissions.owner { "O" } else { " " }; + format!( + "{}{}{}\t{}\t{}\t{}", + rflag, + wflag, + oflag, + key.access_key_id, + key.name, + key.bucket_local_aliases.to_vec().join(","), + ) + })); + format_table(key_info); +} diff --git a/src/garage/cli/remote/cluster.rs b/src/garage/cli/remote/cluster.rs index 9639df8b..78d24245 100644 --- a/src/garage/cli/remote/cluster.rs +++ b/src/garage/cli/remote/cluster.rs @@ -16,7 +16,7 @@ impl Cli { println!("==== HEALTHY NODES ===="); let mut healthy_nodes = - vec!["ID\tHostname\tAddress\tTags\tZone\tCapacity\tDataAvail".to_string()]; + vec!["ID\tHostname\tAddress\tTags\tZone\tCapacity\tDataAvail\tVersion".to_string()]; for adv in status.nodes.iter().filter(|adv| adv.is_up) { let host = adv.hostname.as_deref().unwrap_or("?"); @@ -35,7 +35,7 @@ impl Cli { None => "?".into(), }; healthy_nodes.push(format!( - "{id:.16}\t{host}\t{addr}\t[{tags}]\t{zone}\t{capacity}\t{data_avail}", + "{id:.16}\t{host}\t{addr}\t[{tags}]\t{zone}\t{capacity}\t{data_avail}\t{version}", id = adv.id, host = host, addr = addr, @@ -43,6 +43,7 @@ impl Cli { zone = cfg.zone, capacity = capacity_string(cfg.capacity), data_avail = data_avail, + version = adv.garage_version.as_deref().unwrap_or_default(), )); } else { let status = match layout.staged_role_changes.iter().find(|x| x.id == adv.id) { @@ -54,11 +55,12 @@ impl Cli { _ => "NO ROLE ASSIGNED", }; healthy_nodes.push(format!( - "{id:.16}\t{h}\t{addr}\t\t\t{status}", + "{id:.16}\t{h}\t{addr}\t\t\t{status}\t\t{version}", id = adv.id, h = host, addr = addr, status = status, + version = adv.garage_version.as_deref().unwrap_or_default(), )); } } diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 67843a83..2c6981b6 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -24,10 +24,9 @@ impl Cli { pub async fn cmd_list_keys(&self) -> Result<(), Error> { let keys = self.api_request(ListKeysRequest).await?; - println!("List of keys:"); - let mut table = vec![]; + let mut table = vec!["ID\tName".to_string()]; for key in keys.0.iter() { - table.push(format!("\t{}\t{}", key.id, key.name)); + table.push(format!("{}\t{}", key.id, key.name)); } format_table(table); @@ -185,43 +184,35 @@ impl Cli { } fn print_key_info(key: &GetKeyInfoResponse) { - println!("Key name: {}", key.name); - println!("Key ID: {}", key.access_key_id); - println!( - "Secret key: {}", - key.secret_access_key.as_deref().unwrap_or("(redacted)") - ); - println!("Can create buckets: {}", key.permissions.create_bucket); + println!("==== ACCESS KEY INFORMATION ===="); - println!("\nKey-specific bucket aliases:"); - let mut table = vec![]; - for bucket in key.buckets.iter() { - for la in bucket.local_aliases.iter() { - table.push(format!( - "\t{}\t{}\t{}", - la, - bucket.global_aliases.join(","), - bucket.id - )); - } - } - format_table(table); + format_table(vec![ + format!("Key name:\t{}", key.name), + format!("Key ID:\t{}", key.access_key_id), + format!( + "Secret key:\t{}", + key.secret_access_key.as_deref().unwrap_or("(redacted)") + ), + format!("Can create buckets:\t{}", key.permissions.create_bucket), + ]); - println!("\nAuthorized buckets:"); - let mut table = vec![]; - for bucket in key.buckets.iter() { + println!(""); + println!("==== BUCKETS FOR THIS KEY ===="); + let mut bucket_info = vec!["Permissions\tID\tGlobal aliases\tLocal aliases".to_string()]; + bucket_info.extend(key.buckets.iter().map(|bucket| { let rflag = if bucket.permissions.read { "R" } else { " " }; let wflag = if bucket.permissions.write { "W" } else { " " }; let oflag = if bucket.permissions.owner { "O" } else { " " }; - table.push(format!( - "\t{}{}{}\t{}\t{}\t{:.16}", + format!( + "{}{}{}\t{:.16}\t{}\t{}", rflag, wflag, oflag, - bucket.global_aliases.join(","), + bucket.id, + table_list_abbr(&bucket.global_aliases), bucket.local_aliases.join(","), - bucket.id - )); - } - format_table(table); + ) + })); + + format_table(bucket_info); } diff --git a/src/garage/cli/remote/layout.rs b/src/garage/cli/remote/layout.rs index f350ab66..e243688b 100644 --- a/src/garage/cli/remote/layout.rs +++ b/src/garage/cli/remote/layout.rs @@ -378,7 +378,7 @@ pub fn print_cluster_layout(layout: &GetClusterLayoutResponse, empty_msg: &str) let tags = role.tags.join(","); if let (Some(capacity), Some(usable_capacity)) = (role.capacity, role.usable_capacity) { table.push(format!( - "{:.16}\t{}\t{}\t{}\t{} ({:.1}%)", + "{:.16}\t[{}]\t{}\t{}\t{} ({:.1}%)", role.id, tags, role.zone, @@ -388,7 +388,7 @@ pub fn print_cluster_layout(layout: &GetClusterLayoutResponse, empty_msg: &str) )); } else { table.push(format!( - "{:.16}\t{}\t{}\t{}", + "{:.16}\t[{}]\t{}\t{}", role.id, tags, role.zone, @@ -427,7 +427,7 @@ pub fn print_staging_role_changes(layout: &GetClusterLayoutResponse) -> bool { }) => { let tags = tags.join(","); table.push(format!( - "{:.16}\t{}\t{}\t{}", + "{:.16}\t[{}]\t{}\t{}", change.id, tags, zone, diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs index 237b6db9..f2516427 100644 --- a/src/garage/cli/remote/mod.rs +++ b/src/garage/cli/remote/mod.rs @@ -106,3 +106,15 @@ impl Cli { Ok(resp.success.into_iter().next().unwrap().1) } } + +pub fn table_list_abbr, S: AsRef>(values: T) -> String { + let mut iter = values.into_iter(); + + match iter.next() { + Some(first) => match iter.count() { + 0 => first.as_ref().to_string(), + n => format!("{}, ... ({})", first.as_ref(), n + 1), + }, + None => String::new(), + } +} diff --git a/src/garage/cli/remote/node.rs b/src/garage/cli/remote/node.rs index 419d6bf7..d3017da7 100644 --- a/src/garage/cli/remote/node.rs +++ b/src/garage/cli/remote/node.rs @@ -22,15 +22,22 @@ impl Cli { }) .await?; - let mut table = vec![]; - for (node, err) in res.error.iter() { - table.push(format!("{:.16}\tError: {}", node, err)); - } + let mut table = vec!["Node\tResult".to_string()]; for (node, _) in res.success.iter() { table.push(format!("{:.16}\tSnapshot created", node)); } + for (node, err) in res.error.iter() { + table.push(format!("{:.16}\tError: {}", node, err)); + } format_table(table); + if !res.error.is_empty() { + return Err(Error::Message(format!( + "{} nodes returned an error", + res.error.len() + ))); + } + Ok(()) } @@ -47,19 +54,17 @@ impl Cli { .await?; for (node, res) in res.success.iter() { - println!("======================"); - println!("Stats for node {:.16}:\n", node); + println!("==== NODE [{:.16}] ====", node); println!("{}\n", res.freeform); } for (node, err) in res.error.iter() { - println!("======================"); - println!("Node {:.16}: error: {}\n", node, err); + println!("==== NODE [{:.16}] ====", node); + println!("Error: {}\n", err); } let res = self.api_request(GetClusterStatisticsRequest).await?; - println!("======================"); - println!("Cluster statistics:\n"); + println!("==== CLUSTER STATISTICS ===="); println!("{}\n", res.freeform); Ok(()) diff --git a/src/rpc/layout/version.rs b/src/rpc/layout/version.rs index b7902898..90a51de7 100644 --- a/src/rpc/layout/version.rs +++ b/src/rpc/layout/version.rs @@ -823,7 +823,7 @@ impl LayoutVersion { let total_cap_n = self.expect_get_node_capacity(&self.node_id_vec[*n]); let tags_n = (self.node_role(&self.node_id_vec[*n]).ok_or(""))?.tags_string(); table.push(format!( - " {:?}\t{}\t{} ({} new)\t{}\t{} ({:.1}%)", + " {:?}\t[{}]\t{} ({} new)\t{}\t{} ({:.1}%)", self.node_id_vec[*n], tags_n, stored_partitions[*n], diff --git a/src/rpc/system.rs b/src/rpc/system.rs index 2a52ae5d..198a5f6b 100644 --- a/src/rpc/system.rs +++ b/src/rpc/system.rs @@ -124,6 +124,9 @@ pub struct NodeStatus { /// Hostname of the node pub hostname: Option, + /// Garage version of the node + pub garage_version: Option, + /// Replication factor configured on the node pub replication_factor: usize, @@ -369,6 +372,10 @@ impl System { &self.layout_manager.rpc_helper } + pub fn local_status(&self) -> NodeStatus { + self.local_status.read().unwrap().clone() + } + // ---- Administrative operations (directly available and // also available through RPC) ---- @@ -786,6 +793,7 @@ impl NodeStatus { .into_string() .unwrap_or_else(|_| "".to_string()), ), + garage_version: Some(garage_util::version::garage_version().to_string()), replication_factor: replication_factor.into(), layout_digest: layout_manager.layout().digest(), meta_disk_avail: None, @@ -796,6 +804,7 @@ impl NodeStatus { fn unknown() -> Self { NodeStatus { hostname: None, + garage_version: None, replication_factor: 0, layout_digest: Default::default(), meta_disk_avail: None, diff --git a/src/table/data.rs b/src/table/data.rs index 09f4e008..c589c777 100644 --- a/src/table/data.rs +++ b/src/table/data.rs @@ -66,6 +66,7 @@ impl TableData { store.clone(), merkle_tree.clone(), merkle_todo.clone(), + insert_queue.clone(), gc_todo.clone(), ); @@ -367,6 +368,10 @@ impl TableData { } } + pub fn insert_queue_len(&self) -> Result { + Ok(self.insert_queue.len()?) + } + pub fn gc_todo_len(&self) -> Result { Ok(self.gc_todo.len()?) } diff --git a/src/table/metrics.rs b/src/table/metrics.rs index 7bb0959a..cbbb5bb9 100644 --- a/src/table/metrics.rs +++ b/src/table/metrics.rs @@ -7,6 +7,7 @@ pub struct TableMetrics { pub(crate) _table_size: ValueObserver, pub(crate) _merkle_tree_size: ValueObserver, pub(crate) _merkle_todo_len: ValueObserver, + pub(crate) _insert_queue_len: ValueObserver, pub(crate) _gc_todo_len: ValueObserver, pub(crate) get_request_counter: BoundCounter, @@ -26,6 +27,7 @@ impl TableMetrics { store: db::Tree, merkle_tree: db::Tree, merkle_todo: db::Tree, + insert_queue: db::Tree, gc_todo: db::Tree, ) -> Self { let meter = global::meter(table_name); @@ -72,6 +74,20 @@ impl TableMetrics { ) .with_description("Merkle tree updater TODO queue length") .init(), + _insert_queue_len: meter + .u64_value_observer( + "table.insert_queue_length", + move |observer| { + if let Ok(v) = insert_queue.len() { + observer.observe( + v as u64, + &[KeyValue::new("table_name", table_name)], + ); + } + }, + ) + .with_description("Table insert queue length") + .init(), _gc_todo_len: meter .u64_value_observer( "table.gc_todo_queue_length", From f7d9c2b383832464e4b235cc35599d8cc51e5440 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 15:47:13 +0100 Subject: [PATCH 086/192] cli: add `garage json-api` command and fix cargo tests --- src/garage/Cargo.toml | 1 + src/garage/cli/remote/mod.rs | 44 +++++++++++++++++++++++++++++++ src/garage/cli/structs.rs | 11 ++++++++ src/garage/tests/common/garage.rs | 24 ++++++----------- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index 2ce4fe52..b8ff88ab 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -50,6 +50,7 @@ sodiumoxide.workspace = true structopt.workspace = true git-version.workspace = true utoipa.workspace = true +serde_json.workspace = true futures.workspace = true tokio.workspace = true diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs index f2516427..af79157c 100644 --- a/src/garage/cli/remote/mod.rs +++ b/src/garage/cli/remote/mod.rs @@ -43,6 +43,7 @@ impl Cli { Command::Meta(mo) => self.cmd_meta(mo).await, Command::Stats(so) => self.cmd_stats(so).await, Command::Repair(ro) => self.cmd_repair(ro).await, + Command::JsonApi { endpoint, payload } => self.cmd_json_api(endpoint, payload).await, _ => unreachable!(), } @@ -105,6 +106,49 @@ impl Cli { } Ok(resp.success.into_iter().next().unwrap().1) } + + pub async fn cmd_json_api(&self, endpoint: String, payload: String) -> Result<(), Error> { + let payload: serde_json::Value = if payload == "-" { + serde_json::from_reader(&std::io::stdin())? + } else { + serde_json::from_str(&payload)? + }; + + let request: AdminApiRequest = serde_json::from_value(serde_json::json!({ + endpoint.clone(): payload, + }))?; + + let resp = match self + .proxy_rpc_endpoint + .call(&self.rpc_host, ProxyRpc::Proxy(request), PRIO_NORMAL) + .await?? + { + ProxyRpcResponse::ProxyApiOkResponse(resp) => resp, + ProxyRpcResponse::ApiErrorResponse { + http_code, + error_code, + message, + } => { + return Err(Error::Message(format!( + "{} ({}): {}", + error_code, http_code, message + ))) + } + m => return Err(Error::unexpected_rpc_message(m)), + }; + + if let serde_json::Value::Object(map) = serde_json::to_value(&resp)? { + if let Some(inner) = map.get(&endpoint) { + serde_json::to_writer_pretty(std::io::stdout(), &inner)?; + return Ok(()); + } + } + + Err(Error::Message(format!( + "Invalid response: {}", + serde_json::to_string(&resp)? + ))) + } } pub fn table_list_abbr, S: AsRef>(values: T) -> String { diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index d4446a17..9a6d912c 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -66,6 +66,17 @@ pub enum Command { /// Output openapi JSON schema for admin api #[structopt(name = "admin-api-schema", version = garage_version(), setting(structopt::clap::AppSettings::Hidden))] AdminApiSchema, + + /// Directly invoke the admin API using a JSON payload. + /// The result is printed to `stdout` in JSON format. + #[structopt(name = "json-api", version = garage_version())] + JsonApi { + /// The admin API endpoint to invoke, e.g. GetClusterStatus + endpoint: String, + /// The JSON payload, or `-` to read from `stdin` + #[structopt(default_value = "null")] + payload: String, + }, } // ------------------------- diff --git a/src/garage/tests/common/garage.rs b/src/garage/tests/common/garage.rs index 8d71504f..3d4efbc2 100644 --- a/src/garage/tests/common/garage.rs +++ b/src/garage/tests/common/garage.rs @@ -3,6 +3,8 @@ use std::path::{Path, PathBuf}; use std::process; use std::sync::Once; +use serde_json::json; + use super::ext::*; // https://xkcd.com/221/ @@ -193,27 +195,17 @@ api_bind_addr = "127.0.0.1:{admin_port}" let mut key = Key::default(); let mut cmd = self.command(); - let base = cmd.args(["key", "create"]); + let base = cmd.args(["json-api", "CreateKey"]); let with_name = match maybe_name { - Some(name) => base.args([name]), - None => base, + Some(name) => base.args([serde_json::to_string(&json!({"name": name})).unwrap()]), + None => base.args(["{}"]), }; let output = with_name.expect_success_output("Could not create key"); - let stdout = String::from_utf8(output.stdout).unwrap(); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - for line in stdout.lines() { - if let Some(key_id) = line.strip_prefix("Key ID: ") { - key.id = key_id.to_owned(); - continue; - } - if let Some(key_secret) = line.strip_prefix("Secret key: ") { - key.secret = key_secret.to_owned(); - continue; - } - } - assert!(!key.id.is_empty(), "Invalid key: Key ID is empty"); - assert!(!key.secret.is_empty(), "Invalid key: Key secret is empty"); + key.id = stdout["accessKeyId"].as_str().unwrap().to_string(); + key.secret = stdout["secretAccessKey"].as_str().unwrap().to_string(); key } From 9c745548c45547693c4dc7a242984a8a403233c2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 16:06:28 +0100 Subject: [PATCH 087/192] test-upgrade with v1 -> v2 --- .woodpecker/release.yaml | 10 +++++++++- script/dev-bucket.sh | 14 ++++++++++---- script/dev-configure.sh | 2 +- script/test-upgrade.sh | 7 +++++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 0678a45b..396fbc20 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -35,7 +35,15 @@ steps: - matrix: ARCH: i386 - - name: upgrade tests + - name: upgrade tests from v1.0.0 + image: nixpkgs/nix:nixos-22.05 + commands: + - nix-shell --attr ci --run "./script/test-upgrade.sh v1.0.0 x86_64-unknown-linux-musl" || (cat /tmp/garage.log; false) + when: + - matrix: + ARCH: amd64 + + - name: upgrade tests from v0.8.4 image: nixpkgs/nix:nixos-22.05 commands: - nix-shell --attr ci --run "./script/test-upgrade.sh v0.8.4 x86_64-unknown-linux-musl" || (cat /tmp/garage.log; false) diff --git a/script/dev-bucket.sh b/script/dev-bucket.sh index 708c2c43..82e73652 100755 --- a/script/dev-bucket.sh +++ b/script/dev-bucket.sh @@ -17,13 +17,19 @@ else fi $GARAGE_BIN -c /tmp/config.1.toml bucket create eprouvette -if [ "$GARAGE_08" = "1" ]; then +if [ "$GARAGE_OLDVER" = "v08" ]; then KEY_INFO=$($GARAGE_BIN -c /tmp/config.1.toml key new --name opérateur) -else + ACCESS_KEY=`echo $KEY_INFO|grep -Po 'GK[a-f0-9]+'` + SECRET_KEY=`echo $KEY_INFO|grep -Po 'Secret key: [a-f0-9]+'|grep -Po '[a-f0-9]+$'` +elif [ "$GARAGE_OLDVER" = "v1" ]; then KEY_INFO=$($GARAGE_BIN -c /tmp/config.1.toml key create opérateur) + ACCESS_KEY=`echo $KEY_INFO|grep -Po 'GK[a-f0-9]+'` + SECRET_KEY=`echo $KEY_INFO|grep -Po 'Secret key: [a-f0-9]+'|grep -Po '[a-f0-9]+$'` +else + KEY_INFO=$($GARAGE_BIN -c /tmp/config.1.toml json-api CreateKey '{"name":"opérateur"}') + ACCESS_KEY=`echo $KEY_INFO|jq -r .accessKeyId` + SECRET_KEY=`echo $KEY_INFO|jq -r .secretAccessKey` fi -ACCESS_KEY=`echo $KEY_INFO|grep -Po 'GK[a-f0-9]+'` -SECRET_KEY=`echo $KEY_INFO|grep -Po 'Secret key: [a-f0-9]+'|grep -Po '[a-f0-9]+$'` $GARAGE_BIN -c /tmp/config.1.toml bucket allow eprouvette --read --write --owner --key $ACCESS_KEY echo "$ACCESS_KEY $SECRET_KEY" > /tmp/garage.s3 diff --git a/script/dev-configure.sh b/script/dev-configure.sh index 0649cdbe..86fa84c5 100755 --- a/script/dev-configure.sh +++ b/script/dev-configure.sh @@ -29,7 +29,7 @@ until $GARAGE_BIN -c /tmp/config.1.toml status 2>&1|grep -q HEALTHY ; do sleep 1 done -if [ "$GARAGE_08" = "1" ]; then +if [ "$GARAGE_OLDVER" = "v08" ]; then $GARAGE_BIN -c /tmp/config.1.toml status \ | grep 'NO ROLE' \ | grep -Po '^[0-9a-f]+' \ diff --git a/script/test-upgrade.sh b/script/test-upgrade.sh index dc25e7c6..45eb3c43 100755 --- a/script/test-upgrade.sh +++ b/script/test-upgrade.sh @@ -24,7 +24,10 @@ echo "============= insert data into old version cluster =================" export GARAGE_BIN=/tmp/old_garage if echo $OLD_VERSION | grep 'v0\.8\.'; then echo "Detected Garage v0.8.x" - export GARAGE_08=1 + export GARAGE_OLDVER=v08 +elif (echo $OLD_VERSION | grep 'v0\.9\.') || (echo $OLD_VERSION | grep 'v1\.'); then + echo "Detected Garage v0.9.x / v1.x" + export GARAGE_OLDVER=v1 fi echo "⏳ Setup cluster using old version" @@ -47,7 +50,7 @@ killall -9 old_garage || true echo "🏁 Removing old garage version" rm -rv $GARAGE_BIN export -n GARAGE_BIN -export -n GARAGE_08 +export -n GARAGE_OLDVER echo "================ read data from new cluster ===================" From cfd259190f2a01eee236de72e599859556097acc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 18 Mar 2025 15:10:55 +0100 Subject: [PATCH 088/192] sse-c: use different object encryption key for each object --- Cargo.lock | 1 + src/api/s3/Cargo.toml | 1 + src/api/s3/copy.rs | 45 ++++++++---- src/api/s3/encryption.rs | 130 ++++++++++++++++++++++++++++------- src/api/s3/get.rs | 18 +++-- src/api/s3/list.rs | 14 +++- src/api/s3/multipart.rs | 35 +++++++--- src/api/s3/post_object.rs | 16 ++++- src/api/s3/put.rs | 19 ++++- src/model/s3/object_table.rs | 5 ++ 10 files changed, 225 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9d48116..f64c50dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1403,6 +1403,7 @@ dependencies = [ "garage_table", "garage_util", "hex", + "hmac", "http 1.2.0", "http-body-util", "http-range", diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 7b0cac94..47aaab8c 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -31,6 +31,7 @@ crc32fast.workspace = true crc32c.workspace = true err-derive.workspace = true hex.workspace = true +hmac.workspace = true tracing.workspace = true md-5.workspace = true pin-project.workspace = true diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index a5b2d706..7c67a65d 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -24,7 +24,7 @@ use garage_api_common::helpers::*; use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::get::{full_object_byte_stream, PreconditionHeaders}; use crate::multipart; @@ -65,8 +65,18 @@ pub async fn handle_copy( &ctx.garage, req.headers(), &source_version_meta.encryption, + OekDerivationInfo::for_object(&source_object, source_version), )?; - let dest_encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?; + let dest_uuid = gen_uuid(); + let dest_encryption = EncryptionParams::new_from_headers( + &ctx.garage, + req.headers(), + OekDerivationInfo { + bucket_id: ctx.bucket_id, + version_id: dest_uuid, + object_key: dest_key, + }, + )?; // Extract source checksum info before source_object_meta_inner is consumed let source_checksum = source_object_meta_inner.checksum; @@ -115,6 +125,7 @@ pub async fn handle_copy( handle_copy_metaonly( ctx, dest_key, + dest_uuid, dest_object_meta, dest_encryption, source_version, @@ -138,6 +149,7 @@ pub async fn handle_copy( handle_copy_reencrypt( ctx, dest_key, + dest_uuid, dest_object_meta, dest_encryption, source_version, @@ -169,6 +181,7 @@ pub async fn handle_copy( async fn handle_copy_metaonly( ctx: ReqCtx, dest_key: &str, + dest_uuid: Uuid, dest_object_meta: ObjectVersionMetaInner, dest_encryption: EncryptionParams, source_version: &ObjectVersion, @@ -182,7 +195,6 @@ async fn handle_copy_metaonly( } = ctx; // Generate parameters for copied object - let new_uuid = gen_uuid(); let new_timestamp = now_msec(); let new_meta = ObjectVersionMeta { @@ -192,7 +204,7 @@ async fn handle_copy_metaonly( }; let res = SaveStreamResult { - version_uuid: new_uuid, + version_uuid: dest_uuid, version_timestamp: new_timestamp, etag: new_meta.etag.clone(), }; @@ -204,7 +216,7 @@ async fn handle_copy_metaonly( // bytes is either plaintext before&after or encrypted with the // same keys, so it's ok to just copy it as is let dest_object_version = ObjectVersion { - uuid: new_uuid, + uuid: dest_uuid, timestamp: new_timestamp, state: ObjectVersionState::Complete(ObjectVersionData::Inline( new_meta, @@ -230,7 +242,7 @@ async fn handle_copy_metaonly( // This holds a reference to the object in the Version table // so that it won't be deleted, e.g. by repair_versions. let tmp_dest_object_version = ObjectVersion { - uuid: new_uuid, + uuid: dest_uuid, timestamp: new_timestamp, state: ObjectVersionState::Uploading { encryption: new_meta.encryption.clone(), @@ -250,7 +262,7 @@ async fn handle_copy_metaonly( // marked as deleted (they are marked as deleted only if the Version // doesn't exist or is marked as deleted). let mut dest_version = Version::new( - new_uuid, + dest_uuid, VersionBacklink::Object { bucket_id: dest_bucket_id, key: dest_key.to_string(), @@ -269,7 +281,7 @@ async fn handle_copy_metaonly( .iter() .map(|b| BlockRef { block: b.1.hash, - version: new_uuid, + version: dest_uuid, deleted: false.into(), }) .collect::>(); @@ -285,7 +297,7 @@ async fn handle_copy_metaonly( // with the stuff before, the block's reference counts could be decremented before // they are incremented again for the new version, leading to data being deleted. let dest_object_version = ObjectVersion { - uuid: new_uuid, + uuid: dest_uuid, timestamp: new_timestamp, state: ObjectVersionState::Complete(ObjectVersionData::FirstBlock( new_meta, @@ -307,6 +319,7 @@ async fn handle_copy_metaonly( async fn handle_copy_reencrypt( ctx: ReqCtx, dest_key: &str, + dest_uuid: Uuid, dest_object_meta: ObjectVersionMetaInner, dest_encryption: EncryptionParams, source_version: &ObjectVersion, @@ -326,6 +339,7 @@ async fn handle_copy_reencrypt( save_stream( &ctx, + dest_uuid, dest_object_meta, dest_encryption, source_stream.map_err(|e| Error::from(GarageError::from(e))), @@ -349,7 +363,7 @@ pub async fn handle_upload_part_copy( let dest_upload_id = multipart::decode_upload_id(upload_id)?; let dest_key = dest_key.to_string(); - let (source_object, (_, dest_version, mut dest_mpu)) = futures::try_join!( + let (source_object, (dest_object, dest_version, mut dest_mpu)) = futures::try_join!( get_copy_source(&ctx, req), multipart::get_upload(&ctx, &dest_key, &dest_upload_id) )?; @@ -367,7 +381,10 @@ pub async fn handle_upload_part_copy( &garage, req.headers(), &source_version_meta.encryption, + OekDerivationInfo::for_object(&source_object, source_object_version), )?; + + let dest_oek_params = OekDerivationInfo::for_object(&dest_object, &dest_version); let (dest_object_encryption, dest_object_checksum_algorithm) = match dest_version.state { ObjectVersionState::Uploading { encryption, @@ -376,8 +393,12 @@ pub async fn handle_upload_part_copy( } => (encryption, checksum_algorithm), _ => unreachable!(), }; - let (dest_encryption, _) = - EncryptionParams::check_decrypt(&garage, req.headers(), &dest_object_encryption)?; + let (dest_encryption, _) = EncryptionParams::check_decrypt( + &garage, + req.headers(), + &dest_object_encryption, + dest_oek_params, + )?; let same_encryption = EncryptionParams::is_same(&source_encryption, &dest_encryption); // Check source range is valid diff --git a/src/api/s3/encryption.rs b/src/api/s3/encryption.rs index fa7285ca..c02e126c 100644 --- a/src/api/s3/encryption.rs +++ b/src/api/s3/encryption.rs @@ -11,6 +11,7 @@ use aes_gcm::{ }; use base64::prelude::*; use bytes::Bytes; +use sha2::Sha256; use futures::stream::Stream; use futures::task; @@ -21,12 +22,12 @@ use http::header::{HeaderMap, HeaderName, HeaderValue}; use garage_net::bytes_buf::BytesBuf; use garage_net::stream::{stream_asyncread, ByteStream}; use garage_rpc::rpc_helper::OrderTag; -use garage_util::data::Hash; +use garage_util::data::{Hash, Uuid}; use garage_util::error::Error as GarageError; use garage_util::migrate::Migrate; use garage_model::garage::Garage; -use garage_model::s3::object_table::{ObjectVersionEncryption, ObjectVersionMetaInner}; +use garage_model::s3::object_table::*; use garage_api_common::common_error::*; use garage_api_common::signature::checksum::Md5Checksum; @@ -64,32 +65,45 @@ const STREAM_ENC_CYPER_CHUNK_SIZE: usize = STREAM_ENC_PLAIN_CHUNK_SIZE + 16; pub enum EncryptionParams { Plaintext, SseC { + /// the value of x-amz-server-side-encryption-customer-key client_key: Key, + /// the value of x-amz-server-side-encryption-customer-key-md5 client_key_md5: Md5Output, + /// the object encryption key, for uploads created in garage v2+ + object_key: Option>, + /// the compression level used for compressing data blocks compression_level: Option, }, } +#[derive(Clone, Copy)] +pub struct OekDerivationInfo<'a> { + pub bucket_id: Uuid, + pub version_id: Uuid, + pub object_key: &'a str, +} + impl EncryptionParams { pub fn is_encrypted(&self) -> bool { !matches!(self, Self::Plaintext) } pub fn is_same(a: &Self, b: &Self) -> bool { - let relevant_info = |x: &Self| match x { - Self::Plaintext => None, - Self::SseC { - client_key, - compression_level, - .. - } => Some((*client_key, compression_level.is_some())), - }; - relevant_info(a) == relevant_info(b) + // This function is used in CopyObject and UploadPartCopy to determine + // whether the object must be re-encrypted. If this returns true, + // data blocks are reused as-is. Since Garage v2, we are using + // object-specific encryption keys, so we know that if both source + // and destination are encrypted, it can't be with the same key. + match (a, b) { + (Self::Plaintext, Self::Plaintext) => true, + _ => false, + } } pub fn new_from_headers( garage: &Garage, headers: &HeaderMap, + oek_info: OekDerivationInfo<'_>, ) -> Result { let key = parse_request_headers( headers, @@ -101,6 +115,7 @@ impl EncryptionParams { Some((client_key, client_key_md5)) => Ok(EncryptionParams::SseC { client_key, client_key_md5, + object_key: Some(oek_info.derive_oek(&client_key)), compression_level: garage.config.compression_level, }), None => Ok(EncryptionParams::Plaintext), @@ -126,6 +141,7 @@ impl EncryptionParams { garage: &Garage, headers: &HeaderMap, obj_enc: &'a ObjectVersionEncryption, + oek_info: OekDerivationInfo<'_>, ) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> { let key = parse_request_headers( headers, @@ -133,13 +149,14 @@ impl EncryptionParams { &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, )?; - Self::check_decrypt_common(garage, key, obj_enc) + Self::check_decrypt_common(garage, key, obj_enc, oek_info) } pub fn check_decrypt_for_copy_source<'a>( garage: &Garage, headers: &HeaderMap, obj_enc: &'a ObjectVersionEncryption, + oek_info: OekDerivationInfo<'_>, ) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> { let key = parse_request_headers( headers, @@ -147,22 +164,32 @@ impl EncryptionParams { &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, )?; - Self::check_decrypt_common(garage, key, obj_enc) + Self::check_decrypt_common(garage, key, obj_enc, oek_info) } fn check_decrypt_common<'a>( garage: &Garage, key: Option<(Key, Md5Output)>, obj_enc: &'a ObjectVersionEncryption, + oek_info: OekDerivationInfo<'_>, ) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> { match (key, &obj_enc) { ( Some((client_key, client_key_md5)), - ObjectVersionEncryption::SseC { inner, compressed }, + ObjectVersionEncryption::SseC { + inner, + compressed, + use_oek, + }, ) => { let enc = Self::SseC { client_key, client_key_md5, + object_key: if *use_oek { + Some(oek_info.derive_oek(&client_key)) + } else { + None + }, compression_level: if *compressed { Some(garage.config.compression_level.unwrap_or(1)) } else { @@ -193,13 +220,16 @@ impl EncryptionParams { ) -> Result { match self { Self::SseC { - compression_level, .. + compression_level, + object_key, + .. } => { let plaintext = meta.encode().map_err(GarageError::from)?; let ciphertext = self.encrypt_blob(&plaintext)?; Ok(ObjectVersionEncryption::SseC { inner: ciphertext.into_owned(), compressed: compression_level.is_some(), + use_oek: object_key.is_some(), }) } Self::Plaintext => Ok(ObjectVersionEncryption::Plaintext { inner: meta }), @@ -228,24 +258,37 @@ impl EncryptionParams { // This is used for encrypting object metadata and inlined data for small objects. // This does not compress anything. - pub fn encrypt_blob<'a>(&self, blob: &'a [u8]) -> Result, Error> { + fn cipher(&self) -> Option { match self { - Self::SseC { client_key, .. } => { - let cipher = Aes256Gcm::new(&client_key); + Self::SseC { + object_key: Some(oek), + .. + } => Some(Aes256Gcm::new(&oek)), + Self::SseC { + client_key, + object_key: None, + .. + } => Some(Aes256Gcm::new(&client_key)), + Self::Plaintext => None, + } + } + + pub fn encrypt_blob<'a>(&self, blob: &'a [u8]) -> Result, Error> { + match self.cipher() { + Some(cipher) => { let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, blob) .ok_or_internal_error("Encryption failed")?; Ok(Cow::Owned([nonce.to_vec(), ciphertext].concat())) } - Self::Plaintext => Ok(Cow::Borrowed(blob)), + None => Ok(Cow::Borrowed(blob)), } } pub fn decrypt_blob<'a>(&self, blob: &'a [u8]) -> Result, Error> { - match self { - Self::SseC { client_key, .. } => { - let cipher = Aes256Gcm::new(&client_key); + match self.cipher() { + Some(cipher) => { let nonce_size = ::NonceSize::to_usize(); let nonce = Nonce::from_slice( blob.get(..nonce_size) @@ -258,7 +301,7 @@ impl EncryptionParams { )?; Ok(Cow::Owned(plaintext)) } - Self::Plaintext => Ok(Cow::Borrowed(blob)), + None => Ok(Cow::Borrowed(blob)), } } @@ -284,10 +327,12 @@ impl EncryptionParams { Self::Plaintext => stream, Self::SseC { client_key, + object_key, compression_level, .. } => { - let plaintext = DecryptStream::new(stream, *client_key); + let key = object_key.as_ref().unwrap_or(client_key); + let plaintext = DecryptStream::new(stream, *key); if compression_level.is_some() { let reader = stream_asyncread(Box::pin(plaintext)); let reader = BufReader::new(reader); @@ -307,9 +352,12 @@ impl EncryptionParams { Self::Plaintext => Ok(block), Self::SseC { client_key, + object_key, compression_level, .. } => { + let key = object_key.as_ref().unwrap_or(client_key); + let block = if let Some(level) = compression_level { Cow::Owned( garage_block::zstd_encode(block.as_ref(), *level) @@ -325,7 +373,7 @@ impl EncryptionParams { OsRng.fill_bytes(&mut nonce); ret.extend_from_slice(nonce.as_slice()); - let mut cipher = EncryptorLE31::::new(&client_key, &nonce); + let mut cipher = EncryptorLE31::::new(key, &nonce); let mut iter = block.chunks(STREAM_ENC_PLAIN_CHUNK_SIZE).peekable(); if iter.peek().is_none() { @@ -361,6 +409,13 @@ impl EncryptionParams { } } +pub fn has_encryption_header(headers: &HeaderMap) -> bool { + match headers.get(X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM) { + Some(h) => h.as_bytes() == CUSTOMER_ALGORITHM_AES256, + None => false, + } +} + fn parse_request_headers( headers: &HeaderMap, alg_header: &HeaderName, @@ -420,6 +475,30 @@ fn parse_request_headers( } } +impl<'a> OekDerivationInfo<'a> { + pub fn for_object<'b>(object: &'a Object, version: &'b ObjectVersion) -> Self { + Self { + bucket_id: object.bucket_id, + version_id: version.uuid, + object_key: &object.key, + } + } + + fn derive_oek(&self, client_key: &Key) -> Key { + use hmac::{Hmac, Mac}; + + // info = bucket_id + object_name + version_uuid + "garage-object-encryption-key" + // oek = hmac_sha256(ssec_key, info) + let mut hmac = as Mac>::new_from_slice(client_key.as_slice()) + .expect("create hmac-sha256"); + hmac.update(b"garage-object-encryption-key"); + hmac.update(self.bucket_id.as_slice()); + hmac.update(self.version_id.as_slice()); + hmac.update(self.object_key.as_bytes()); + hmac.finalize().into_bytes() + } +} + // ---- encrypt & decrypt streams ---- #[pin_project::pin_project] @@ -569,6 +648,7 @@ mod tests { let enc = EncryptionParams::SseC { client_key: Aes256Gcm::generate_key(&mut OsRng), client_key_md5: Default::default(), // not needed + object_key: Some(Aes256Gcm::generate_key(&mut OsRng)), compression_level, }; diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index 22076603..723e6775 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -30,7 +30,7 @@ use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AM use crate::api_server::ResBody; use crate::copy::*; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; const X_AMZ_MP_PARTS_COUNT: HeaderName = HeaderName::from_static("x-amz-mp-parts-count"); @@ -181,8 +181,12 @@ pub async fn handle_head_without_ctx( return Ok(res); } - let (encryption, headers) = - EncryptionParams::check_decrypt(&garage, req.headers(), &version_meta.encryption)?; + let (encryption, headers) = EncryptionParams::check_decrypt( + &garage, + req.headers(), + &version_meta.encryption, + OekDerivationInfo::for_object(&object, object_version), + )?; let checksum_mode = checksum_mode(&req); @@ -303,8 +307,12 @@ pub async fn handle_get_without_ctx( return Ok(res); } - let (enc, headers) = - EncryptionParams::check_decrypt(&garage, req.headers(), &last_v_meta.encryption)?; + let (enc, headers) = EncryptionParams::check_decrypt( + &garage, + req.headers(), + &last_v_meta.encryption, + OekDerivationInfo::for_object(&object, last_v), + )?; let checksum_mode = checksum_mode(&req); diff --git a/src/api/s3/list.rs b/src/api/s3/list.rs index 94c2c895..ff5ca383 100644 --- a/src/api/s3/list.rs +++ b/src/api/s3/list.rs @@ -17,7 +17,7 @@ use garage_api_common::encoding::*; use garage_api_common::helpers::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::multipart as s3_multipart; use crate::xml as s3_xml; @@ -285,8 +285,16 @@ pub async fn handle_list_parts( ObjectVersionState::Uploading { encryption, .. } => encryption, _ => unreachable!(), }; - let encryption_res = - EncryptionParams::check_decrypt(&ctx.garage, req.headers(), &object_encryption); + let encryption_res = EncryptionParams::check_decrypt( + &ctx.garage, + req.headers(), + &object_encryption, + OekDerivationInfo { + bucket_id: ctx.bucket_id, + version_id: upload_id, + object_key: &query.key, + }, + ); let (info, next) = fetch_part_info(query, &mpu)?; diff --git a/src/api/s3/multipart.rs b/src/api/s3/multipart.rs index d6eb26cb..52ea90e8 100644 --- a/src/api/s3/multipart.rs +++ b/src/api/s3/multipart.rs @@ -26,7 +26,7 @@ use garage_api_common::helpers::*; use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{has_encryption_header, EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::put::*; use crate::xml as s3_xml; @@ -56,7 +56,15 @@ pub async fn handle_create_multipart_upload( }; // Determine whether object should be encrypted, and if so the key - let encryption = EncryptionParams::new_from_headers(&garage, req.headers())?; + let encryption = EncryptionParams::new_from_headers( + &garage, + req.headers(), + OekDerivationInfo { + bucket_id: *bucket_id, + version_id: upload_id, + object_key: &key, + }, + )?; let object_encryption = encryption.encrypt_meta(meta)?; let checksum_algorithm = request_checksum_algorithm(req.headers())?; @@ -120,8 +128,7 @@ pub async fn handle_put_part( // Before we stream the body, configure the needed checksums. req_body.add_expected_checksums(expected_checksums.clone()); - // TODO: avoid parsing encryption headers twice... - if !EncryptionParams::new_from_headers(&garage, &req_head.headers)?.is_encrypted() { + if !has_encryption_header(&req_head.headers) { // For non-encrypted objects, we need to compute the md5sum in all cases // (even if content-md5 is not set), because it is used as an etag of the // part, which is in turn used in the etag computation of the whole object @@ -134,10 +141,11 @@ pub async fn handle_put_part( let mut chunker = StreamChunker::new(stream, garage.config.block_size); // Read first chuck, and at the same time try to get object to see if it exists - let ((_, object_version, mut mpu), first_block) = + let ((object, object_version, mut mpu), first_block) = futures::try_join!(get_upload(&ctx, &key, &upload_id), chunker.next(),)?; // Check encryption params + let oek_params = OekDerivationInfo::for_object(&object, &object_version); let (object_encryption, checksum_algorithm) = match object_version.state { ObjectVersionState::Uploading { encryption, @@ -146,8 +154,12 @@ pub async fn handle_put_part( } => (encryption, checksum_algorithm), _ => unreachable!(), }; - let (encryption, _) = - EncryptionParams::check_decrypt(&garage, &req_head.headers, &object_encryption)?; + let (encryption, _) = EncryptionParams::check_decrypt( + &garage, + &req_head.headers, + &object_encryption, + oek_params, + )?; // Check object is valid and part can be accepted let first_block = first_block.ok_or_bad_request("Empty body")?; @@ -297,6 +309,7 @@ pub async fn handle_complete_multipart_upload( return Err(Error::bad_request("No data was uploaded")); } + let oek_params = OekDerivationInfo::for_object(&object, &object_version); let (object_encryption, checksum_algorithm) = match object_version.state { ObjectVersionState::Uploading { encryption, @@ -417,8 +430,12 @@ pub async fn handle_complete_multipart_upload( let object_encryption = match checksum_algorithm { None => object_encryption, Some(_) => { - let (encryption, meta) = - EncryptionParams::check_decrypt(&garage, &req_head.headers, &object_encryption)?; + let (encryption, meta) = EncryptionParams::check_decrypt( + &garage, + &req_head.headers, + &object_encryption, + oek_params, + )?; let new_meta = ObjectVersionMetaInner { headers: meta.into_owned().headers, checksum: checksum_extra, diff --git a/src/api/s3/post_object.rs b/src/api/s3/post_object.rs index b9bccae6..01c50ebc 100644 --- a/src/api/s3/post_object.rs +++ b/src/api/s3/post_object.rs @@ -15,6 +15,7 @@ use serde::Deserialize; use garage_model::garage::Garage; use garage_model::s3::object_table::*; +use garage_util::data::gen_uuid; use garage_api_common::cors::*; use garage_api_common::helpers::*; @@ -22,7 +23,7 @@ use garage_api_common::signature::checksum::*; use garage_api_common::signature::payload::{verify_v4, Authorization}; use crate::api_server::ResBody; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::put::{extract_metadata_headers, save_stream, ChecksumMode}; use crate::xml as s3_xml; @@ -231,12 +232,22 @@ pub async fn handle_post_object( .transpose()?, }; + let version_uuid = gen_uuid(); + let meta = ObjectVersionMetaInner { headers, checksum: expected_checksums.extra, }; - let encryption = EncryptionParams::new_from_headers(&garage, ¶ms)?; + let encryption = EncryptionParams::new_from_headers( + &garage, + ¶ms, + OekDerivationInfo { + bucket_id, + version_id: version_uuid, + object_key: &key, + }, + )?; let stream = file_field.map(|r| r.map_err(Into::into)); let ctx = ReqCtx { @@ -249,6 +260,7 @@ pub async fn handle_post_object( let res = save_stream( &ctx, + version_uuid, meta, encryption, StreamLimiter::new(stream, conditions.content_length), diff --git a/src/api/s3/put.rs b/src/api/s3/put.rs index 830a7998..425636b6 100644 --- a/src/api/s3/put.rs +++ b/src/api/s3/put.rs @@ -35,7 +35,7 @@ use garage_api_common::signature::body::StreamingChecksumReceiver; use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION; @@ -62,6 +62,10 @@ pub async fn handle_put( req: Request, key: &String, ) -> Result, Error> { + // Generate version uuid now, because it is necessary to compute SSE-C + // encryption parameters + let version_uuid = gen_uuid(); + // Retrieve interesting headers from request let headers = extract_metadata_headers(req.headers())?; debug!("Object headers: {:?}", headers); @@ -82,7 +86,15 @@ pub async fn handle_put( }; // Determine whether object should be encrypted, and if so the key - let encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?; + let encryption = EncryptionParams::new_from_headers( + &ctx.garage, + req.headers(), + OekDerivationInfo { + bucket_id: ctx.bucket_id, + version_id: version_uuid, + object_key: &key, + }, + )?; // The request body is a special ReqBody object (see garage_api_common::signature::body) // which supports calculating checksums while streaming the data. @@ -100,6 +112,7 @@ pub async fn handle_put( let res = save_stream( &ctx, + version_uuid, meta, encryption, stream, @@ -121,6 +134,7 @@ pub async fn handle_put( pub(crate) async fn save_stream> + Unpin>( ctx: &ReqCtx, + version_uuid: Uuid, mut meta: ObjectVersionMetaInner, encryption: EncryptionParams, body: S, @@ -140,7 +154,6 @@ pub(crate) async fn save_stream> + Unpin>( let first_block = first_block_opt.unwrap_or_default(); // Generate identity of new version - let version_uuid = gen_uuid(); let version_timestamp = next_timestamp(existing_object.as_ref()); let mut checksummer = match &checksum_mode { diff --git a/src/model/s3/object_table.rs b/src/model/s3/object_table.rs index 6c33b79b..f6204766 100644 --- a/src/model/s3/object_table.rs +++ b/src/model/s3/object_table.rs @@ -257,6 +257,11 @@ mod v010 { /// (compression happens before encryption, whereas for non-encrypted /// objects, compression is handled at the level of the block manager) compressed: bool, + /// Whether the encryption uses an Object Encryption Key derived + /// from the master SSE-C key, instead of the master SSE-C key itself. + /// This is the case of objects created in Garage v2+ + #[serde(default)] + use_oek: bool, }, Plaintext { /// Plain-text headers From 97e2fa5b8b10873b021dc6e73901b20fb55ea705 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 18 Mar 2025 16:42:03 +0100 Subject: [PATCH 089/192] add upgrade test for sse-c --- script/test-upgrade.sh | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/script/test-upgrade.sh b/script/test-upgrade.sh index 45eb3c43..8f66ab8b 100755 --- a/script/test-upgrade.sh +++ b/script/test-upgrade.sh @@ -30,6 +30,11 @@ elif (echo $OLD_VERSION | grep 'v0\.9\.') || (echo $OLD_VERSION | grep 'v1\.'); export GARAGE_OLDVER=v1 fi +if echo $OLD_VERSION | grep 'v1\.'; then + DO_SSEC_TEST=1 +fi +SSEC_KEY="u8zCfnEyt5Imo/krN+sxA1DQXxLWtPJavU6T6gOVj1Y=" + echo "⏳ Setup cluster using old version" $GARAGE_BIN --version ${SCRIPT_FOLDER}/dev-clean.sh @@ -40,7 +45,23 @@ ${SCRIPT_FOLDER}/dev-bucket.sh echo "🛠️ Inserting data in old cluster" source ${SCRIPT_FOLDER}/dev-env-rclone.sh -rclone copy "${SCRIPT_FOLDER}/../.git/" garage:eprouvette/test_dotgit --stats=1s --stats-log-level=NOTICE --stats-one-line +rclone copy "${SCRIPT_FOLDER}/../.git/" garage:eprouvette/test_dotgit \ + --stats=1s --stats-log-level=NOTICE --stats-one-line + +if [ "$DO_SSEC_TEST" = "1" ]; then + # upload small file (should be single part) + rclone copy "${SCRIPT_FOLDER}/test-upgrade.sh" garage:eprouvette/test-ssec \ + --s3-sse-customer-algorithm AES256 \ + --s3-sse-customer-key-base64 "$SSEC_KEY" \ + --stats=1s --stats-log-level=NOTICE --stats-one-line + # do a multipart upload + dd if=/dev/urandom of=/tmp/randfile-for-upgrade bs=5M count=5 + rclone copy "/tmp/randfile-for-upgrade" garage:eprouvette/test-ssec \ + --s3-chunk-size 5M \ + --s3-sse-customer-algorithm AES256 \ + --s3-sse-customer-key-base64 "$SSEC_KEY" \ + --stats=1s --stats-log-level=NOTICE --stats-one-line +fi echo "🏁 Stopping old cluster" killall -INT old_garage @@ -63,7 +84,8 @@ ${SCRIPT_FOLDER}/dev-cluster.sh >> /tmp/garage.log 2>&1 & sleep 3 echo "🛠️ Retrieving data from old cluster" -rclone copy garage:eprouvette/test_dotgit /tmp/test_dotgit --stats=1s --stats-log-level=NOTICE --stats-one-line --fast-list +rclone copy garage:eprouvette/test_dotgit /tmp/test_dotgit \ + --stats=1s --stats-log-level=NOTICE --stats-one-line --fast-list if ! diff <(find "${SCRIPT_FOLDER}/../.git" -type f | xargs md5sum | cut -d ' ' -f 1 | sort) <(find /tmp/test_dotgit -type f | xargs md5sum | cut -d ' ' -f 1 | sort); then echo "TEST FAILURE: directories are different" @@ -71,6 +93,23 @@ if ! diff <(find "${SCRIPT_FOLDER}/../.git" -type f | xargs md5sum | cut -d ' ' fi rm -r /tmp/test_dotgit +if [ "$DO_SSEC_TEST" = "1" ]; then + rclone copy garage:eprouvette/test-ssec /tmp/test_ssec_out \ + --s3-sse-customer-algorithm AES256 \ + --s3-sse-customer-key-base64 "$SSEC_KEY" \ + --stats=1s --stats-log-level=NOTICE --stats-one-line + if ! diff "/tmp/test_ssec_out/test-upgrade.sh" "${SCRIPT_FOLDER}/test-upgrade.sh"; then + echo "SSEC-FAILURE (small file)" + exit 1 + fi + if ! diff "/tmp/test_ssec_out/randfile-for-upgrade" "/tmp/randfile-for-upgrade"; then + echo "SSEC-FAILURE (big file)" + exit 1 + fi + rm -r /tmp/test_ssec_out + rm /tmp/randfile-for-upgrade +fi + echo "🏁 Teardown" rm -rf /tmp/garage-{data,meta}-* rm -rf /tmp/config.*.toml From a826c361a9f9adb45ae7499da07f71c966897e82 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 19 Mar 2025 15:51:06 +0100 Subject: [PATCH 090/192] add crc64nvme checksumming algorithm (fix #963) --- Cargo.lock | 26 +++++++++++++++++++ Cargo.toml | 1 + src/api/common/Cargo.toml | 1 + src/api/common/signature/checksum.rs | 39 ++++++++++++++++++++++++++++ src/api/s3/Cargo.toml | 1 + src/api/s3/list.rs | 6 +++++ src/api/s3/multipart.rs | 27 +++++++++++++++++++ src/api/s3/xml.rs | 7 +++++ src/model/s3/object_table.rs | 3 +++ 9 files changed, 111 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f64c50dc..01fee410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,6 +812,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32c" version = "0.6.8" @@ -830,6 +845,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crc64fast-nvme" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4955638f00a809894c947f85a024020a20815b65a5eea633798ea7924edab2b3" +dependencies = [ + "crc", +] + [[package]] name = "crossbeam-channel" version = "0.5.14" @@ -1332,6 +1356,7 @@ dependencies = [ "chrono", "crc32c", "crc32fast", + "crc64fast-nvme", "crypto-common", "err-derive", "futures", @@ -1392,6 +1417,7 @@ dependencies = [ "chrono", "crc32c", "crc32fast", + "crc64fast-nvme", "err-derive", "form_urlencoded", "futures", diff --git a/Cargo.toml b/Cargo.toml index ab35f757..42deb99b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ cfg-if = "1.0" chrono = { version = "0.4", features = ["serde"] } crc32fast = "1.4" crc32c = "0.6" +crc64fast-nvme = "1.2" crypto-common = "0.1" err-derive = "0.3" gethostname = "0.4" diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index 6d906423..5608a5e3 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -23,6 +23,7 @@ bytes.workspace = true chrono.workspace = true crc32fast.workspace = true crc32c.workspace = true +crc64fast-nvme.workspace = true crypto-common.workspace = true err-derive.workspace = true hex.workspace = true diff --git a/src/api/common/signature/checksum.rs b/src/api/common/signature/checksum.rs index 3c5e7c53..0fb66ce5 100644 --- a/src/api/common/signature/checksum.rs +++ b/src/api/common/signature/checksum.rs @@ -4,6 +4,7 @@ use std::hash::Hasher; use base64::prelude::*; use crc32c::Crc32cHasher as Crc32c; use crc32fast::Hasher as Crc32; +use crc64fast_nvme::Digest as Crc64Nvme; use md5::{Digest, Md5}; use sha1::Sha1; use sha2::Sha256; @@ -23,11 +24,14 @@ pub const X_AMZ_CHECKSUM_ALGORITHM: HeaderName = pub const X_AMZ_CHECKSUM_MODE: HeaderName = HeaderName::from_static("x-amz-checksum-mode"); pub const X_AMZ_CHECKSUM_CRC32: HeaderName = HeaderName::from_static("x-amz-checksum-crc32"); pub const X_AMZ_CHECKSUM_CRC32C: HeaderName = HeaderName::from_static("x-amz-checksum-crc32c"); +pub const X_AMZ_CHECKSUM_CRC64NVME: HeaderName = + HeaderName::from_static("x-amz-checksum-crc64nvme"); pub const X_AMZ_CHECKSUM_SHA1: HeaderName = HeaderName::from_static("x-amz-checksum-sha1"); pub const X_AMZ_CHECKSUM_SHA256: HeaderName = HeaderName::from_static("x-amz-checksum-sha256"); pub type Crc32Checksum = [u8; 4]; pub type Crc32cChecksum = [u8; 4]; +pub type Crc64NvmeChecksum = [u8; 8]; pub type Md5Checksum = [u8; 16]; pub type Sha1Checksum = [u8; 20]; pub type Sha256Checksum = [u8; 32]; @@ -45,6 +49,7 @@ pub struct ExpectedChecksums { pub struct Checksummer { pub crc32: Option, pub crc32c: Option, + pub crc64nvme: Option, pub md5: Option, pub sha1: Option, pub sha256: Option, @@ -54,6 +59,7 @@ pub struct Checksummer { pub struct Checksums { pub crc32: Option, pub crc32c: Option, + pub crc64nvme: Option, pub md5: Option, pub sha1: Option, pub sha256: Option, @@ -64,6 +70,7 @@ impl Checksummer { Self { crc32: None, crc32c: None, + crc64nvme: None, md5: None, sha1: None, sha256: None, @@ -96,6 +103,9 @@ impl Checksummer { if matches!(&expected.extra, Some(ChecksumValue::Crc32c(_))) { self.crc32c = Some(Crc32c::default()); } + if matches!(&expected.extra, Some(ChecksumValue::Crc64Nvme(_))) { + self.crc64nvme = Some(Crc64Nvme::default()); + } if matches!(&expected.extra, Some(ChecksumValue::Sha1(_))) { self.sha1 = Some(Sha1::new()); } @@ -109,6 +119,9 @@ impl Checksummer { Some(ChecksumAlgorithm::Crc32c) => { self.crc32c = Some(Crc32c::default()); } + Some(ChecksumAlgorithm::Crc64Nvme) => { + self.crc64nvme = Some(Crc64Nvme::default()); + } Some(ChecksumAlgorithm::Sha1) => { self.sha1 = Some(Sha1::new()); } @@ -127,6 +140,9 @@ impl Checksummer { if let Some(crc32c) = &mut self.crc32c { crc32c.write(bytes); } + if let Some(crc64nvme) = &mut self.crc64nvme { + crc64nvme.write(bytes); + } if let Some(md5) = &mut self.md5 { md5.update(bytes); } @@ -144,6 +160,7 @@ impl Checksummer { crc32c: self .crc32c .map(|x| u32::to_be_bytes(u32::try_from(x.finish()).unwrap())), + crc64nvme: self.crc64nvme.map(|x| u64::to_be_bytes(x.sum64())), md5: self.md5.map(|x| x.finalize()[..].try_into().unwrap()), sha1: self.sha1.map(|x| x.finalize()[..].try_into().unwrap()), sha256: self.sha256.map(|x| x.finalize()[..].try_into().unwrap()), @@ -190,6 +207,9 @@ impl Checksums { None => None, Some(ChecksumAlgorithm::Crc32) => Some(ChecksumValue::Crc32(self.crc32.unwrap())), Some(ChecksumAlgorithm::Crc32c) => Some(ChecksumValue::Crc32c(self.crc32c.unwrap())), + Some(ChecksumAlgorithm::Crc64Nvme) => { + Some(ChecksumValue::Crc64Nvme(self.crc64nvme.unwrap())) + } Some(ChecksumAlgorithm::Sha1) => Some(ChecksumValue::Sha1(self.sha1.unwrap())), Some(ChecksumAlgorithm::Sha256) => Some(ChecksumValue::Sha256(self.sha256.unwrap())), } @@ -202,6 +222,7 @@ pub fn parse_checksum_algorithm(algo: &str) -> Result match algo { "CRC32" => Ok(ChecksumAlgorithm::Crc32), "CRC32C" => Ok(ChecksumAlgorithm::Crc32c), + "CRC64NVME" => Ok(ChecksumAlgorithm::Crc64Nvme), "SHA1" => Ok(ChecksumAlgorithm::Sha1), "SHA256" => Ok(ChecksumAlgorithm::Sha256), _ => Err(Error::bad_request("invalid checksum algorithm")), @@ -225,6 +246,7 @@ pub fn request_trailer_checksum_algorithm( None => Ok(None), Some(x) if x == X_AMZ_CHECKSUM_CRC32 => Ok(Some(ChecksumAlgorithm::Crc32)), Some(x) if x == X_AMZ_CHECKSUM_CRC32C => Ok(Some(ChecksumAlgorithm::Crc32c)), + Some(x) if x == X_AMZ_CHECKSUM_CRC64NVME => Ok(Some(ChecksumAlgorithm::Crc64Nvme)), Some(x) if x == X_AMZ_CHECKSUM_SHA1 => Ok(Some(ChecksumAlgorithm::Sha1)), Some(x) if x == X_AMZ_CHECKSUM_SHA256 => Ok(Some(ChecksumAlgorithm::Sha256)), _ => Err(Error::bad_request("invalid checksum algorithm")), @@ -243,6 +265,12 @@ pub fn request_checksum_value( if headers.contains_key(X_AMZ_CHECKSUM_CRC32C) { ret.push(extract_checksum_value(headers, ChecksumAlgorithm::Crc32c)?); } + if headers.contains_key(X_AMZ_CHECKSUM_CRC64NVME) { + ret.push(extract_checksum_value( + headers, + ChecksumAlgorithm::Crc64Nvme, + )?); + } if headers.contains_key(X_AMZ_CHECKSUM_SHA1) { ret.push(extract_checksum_value(headers, ChecksumAlgorithm::Sha1)?); } @@ -281,6 +309,14 @@ pub fn extract_checksum_value( .ok_or_bad_request("invalid x-amz-checksum-crc32c header")?; Ok(ChecksumValue::Crc32c(crc32c)) } + ChecksumAlgorithm::Crc64Nvme => { + let crc64nvme = headers + .get(X_AMZ_CHECKSUM_CRC64NVME) + .and_then(|x| BASE64_STANDARD.decode(&x).ok()) + .and_then(|x| x.try_into().ok()) + .ok_or_bad_request("invalid x-amz-checksum-crc64nvme header")?; + Ok(ChecksumValue::Crc64Nvme(crc64nvme)) + } ChecksumAlgorithm::Sha1 => { let sha1 = headers .get(X_AMZ_CHECKSUM_SHA1) @@ -311,6 +347,9 @@ pub fn add_checksum_response_headers( Some(ChecksumValue::Crc32c(crc32c)) => { resp = resp.header(X_AMZ_CHECKSUM_CRC32C, BASE64_STANDARD.encode(&crc32c)); } + Some(ChecksumValue::Crc64Nvme(crc64nvme)) => { + resp = resp.header(X_AMZ_CHECKSUM_CRC64NVME, BASE64_STANDARD.encode(&crc64nvme)); + } Some(ChecksumValue::Sha1(sha1)) => { resp = resp.header(X_AMZ_CHECKSUM_SHA1, BASE64_STANDARD.encode(&sha1)); } diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 47aaab8c..e236729f 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -29,6 +29,7 @@ bytes.workspace = true chrono.workspace = true crc32fast.workspace = true crc32c.workspace = true +crc64fast-nvme.workspace = true err-derive.workspace = true hex.workspace = true hmac.workspace = true diff --git a/src/api/s3/list.rs b/src/api/s3/list.rs index ff5ca383..797fdec0 100644 --- a/src/api/s3/list.rs +++ b/src/api/s3/list.rs @@ -334,6 +334,12 @@ pub async fn handle_list_parts( } _ => None, }, + checksum_crc64nvme: match &checksum { + Some(ChecksumValue::Crc64Nvme(x)) => { + Some(s3_xml::Value(BASE64_STANDARD.encode(&x))) + } + _ => None, + }, checksum_sha1: match &checksum { Some(ChecksumValue::Sha1(x)) => { Some(s3_xml::Value(BASE64_STANDARD.encode(&x))) diff --git a/src/api/s3/multipart.rs b/src/api/s3/multipart.rs index 52ea90e8..2758c273 100644 --- a/src/api/s3/multipart.rs +++ b/src/api/s3/multipart.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use base64::prelude::*; use crc32c::Crc32cHasher as Crc32c; use crc32fast::Hasher as Crc32; +use crc64fast_nvme::Digest as Crc64Nvme; use futures::prelude::*; use hyper::{Request, Response}; use md5::{Digest, Md5}; @@ -481,6 +482,10 @@ pub async fn handle_complete_multipart_upload( Some(ChecksumValue::Crc32c(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))), _ => None, }, + checksum_crc64nvme: match &checksum_extra { + Some(ChecksumValue::Crc64Nvme(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))), + _ => None, + }, checksum_sha1: match &checksum_extra { Some(ChecksumValue::Sha1(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))), _ => None, @@ -604,6 +609,15 @@ fn parse_complete_multipart_upload_body( .try_into() .ok()?, )) + } else if let Some(crc64nvme) = item + .children() + .find(|e| e.has_tag_name("ChecksumCRC64NVME")) + { + Some(ChecksumValue::Crc64Nvme( + BASE64_STANDARD.decode(crc64nvme.text()?).ok()?[..] + .try_into() + .ok()?, + )) } else if let Some(sha1) = item.children().find(|e| e.has_tag_name("ChecksumSHA1")) { Some(ChecksumValue::Sha1( BASE64_STANDARD.decode(sha1.text()?).ok()?[..] @@ -644,6 +658,7 @@ pub(crate) struct MultipartChecksummer { pub(crate) enum MultipartExtraChecksummer { Crc32(Crc32), Crc32c(Crc32c), + Crc64Nvme(Crc64Nvme), Sha1(Sha1), Sha256(Sha256), } @@ -660,6 +675,9 @@ impl MultipartChecksummer { Some(ChecksumAlgorithm::Crc32c) => { Some(MultipartExtraChecksummer::Crc32c(Crc32c::default())) } + Some(ChecksumAlgorithm::Crc64Nvme) => { + Some(MultipartExtraChecksummer::Crc64Nvme(Crc64Nvme::default())) + } Some(ChecksumAlgorithm::Sha1) => Some(MultipartExtraChecksummer::Sha1(Sha1::new())), Some(ChecksumAlgorithm::Sha256) => { Some(MultipartExtraChecksummer::Sha256(Sha256::new())) @@ -689,6 +707,12 @@ impl MultipartChecksummer { ) => { crc32c.write(&x); } + ( + Some(MultipartExtraChecksummer::Crc64Nvme(ref mut crc64nvme)), + Some(ChecksumValue::Crc64Nvme(x)), + ) => { + crc64nvme.write(&x); + } (Some(MultipartExtraChecksummer::Sha1(ref mut sha1)), Some(ChecksumValue::Sha1(x))) => { sha1.update(&x); } @@ -718,6 +742,9 @@ impl MultipartChecksummer { Some(MultipartExtraChecksummer::Crc32c(crc32c)) => Some(ChecksumValue::Crc32c( u32::to_be_bytes(u32::try_from(crc32c.finish()).unwrap()), )), + Some(MultipartExtraChecksummer::Crc64Nvme(crc64nvme)) => Some( + ChecksumValue::Crc64Nvme(u64::to_be_bytes(crc64nvme.sum64())), + ), Some(MultipartExtraChecksummer::Sha1(sha1)) => { Some(ChecksumValue::Sha1(sha1.finalize()[..].try_into().unwrap())) } diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index e8af3ec0..7dea3d1c 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -135,6 +135,8 @@ pub struct CompleteMultipartUploadResult { pub checksum_crc32: Option, #[serde(rename = "ChecksumCRC32C")] pub checksum_crc32c: Option, + #[serde(rename = "ChecksumCR64NVME")] + pub checksum_crc64nvme: Option, #[serde(rename = "ChecksumSHA1")] pub checksum_sha1: Option, #[serde(rename = "ChecksumSHA256")] @@ -209,6 +211,8 @@ pub struct PartItem { pub checksum_crc32: Option, #[serde(rename = "ChecksumCRC32C")] pub checksum_crc32c: Option, + #[serde(rename = "ChecksumCRC64NVME")] + pub checksum_crc64nvme: Option, #[serde(rename = "ChecksumSHA1")] pub checksum_sha1: Option, #[serde(rename = "ChecksumSHA256")] @@ -518,6 +522,7 @@ mod tests { etag: Value("\"3858f62230ac3c915f300c664312c11f-9\"".to_string()), checksum_crc32: None, checksum_crc32c: None, + checksum_crc64nvme: None, checksum_sha1: Some(Value("ZJAnHyG8PeKz9tI8UTcHrJos39A=".into())), checksum_sha256: None, }; @@ -803,6 +808,7 @@ mod tests { size: IntValue(10485760), checksum_crc32: None, checksum_crc32c: None, + checksum_crc64nvme: None, checksum_sha256: Some(Value( "5RQ3A5uk0w7ojNjvegohch4JRBBGN/cLhsNrPzfv/hA=".into(), )), @@ -816,6 +822,7 @@ mod tests { checksum_sha256: None, checksum_crc32c: None, checksum_crc32: Some(Value("ZJAnHyG8=".into())), + checksum_crc64nvme: None, checksum_sha1: None, }, ], diff --git a/src/model/s3/object_table.rs b/src/model/s3/object_table.rs index f6204766..3d805d1a 100644 --- a/src/model/s3/object_table.rs +++ b/src/model/s3/object_table.rs @@ -282,6 +282,7 @@ mod v010 { pub enum ChecksumAlgorithm { Crc32, Crc32c, + Crc64Nvme, Sha1, Sha256, } @@ -291,6 +292,7 @@ mod v010 { pub enum ChecksumValue { Crc32(#[serde(with = "serde_bytes")] [u8; 4]), Crc32c(#[serde(with = "serde_bytes")] [u8; 4]), + Crc64Nvme(#[serde(with = "serde_bytes")] [u8; 8]), Sha1(#[serde(with = "serde_bytes")] [u8; 20]), Sha256(#[serde(with = "serde_bytes")] [u8; 32]), } @@ -492,6 +494,7 @@ impl ChecksumValue { match self { ChecksumValue::Crc32(_) => ChecksumAlgorithm::Crc32, ChecksumValue::Crc32c(_) => ChecksumAlgorithm::Crc32c, + ChecksumValue::Crc64Nvme(_) => ChecksumAlgorithm::Crc64Nvme, ChecksumValue::Sha1(_) => ChecksumAlgorithm::Sha1, ChecksumValue::Sha256(_) => ChecksumAlgorithm::Sha256, } From 2f2a96b51d4061224caeec434273aabb4208f9d6 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 11:05:00 +0100 Subject: [PATCH 091/192] layout & replication mode refactoring --- src/rpc/layout/helper.rs | 70 ++++++++++++-------------------- src/rpc/layout/history.rs | 4 +- src/rpc/layout/manager.rs | 8 +++- src/rpc/layout/version.rs | 4 +- src/rpc/rpc_helper.rs | 4 +- src/rpc/system.rs | 5 +-- src/table/replication/sharded.rs | 21 ++++++++-- 7 files changed, 55 insertions(+), 61 deletions(-) diff --git a/src/rpc/layout/helper.rs b/src/rpc/layout/helper.rs index c08a5629..482a2eea 100644 --- a/src/rpc/layout/helper.rs +++ b/src/rpc/layout/helper.rs @@ -149,14 +149,27 @@ impl LayoutHelper { self.layout.as_ref().unwrap() } + /// Returns the current layout version pub fn current(&self) -> &LayoutVersion { self.inner().current() } + /// Returns all layout versions currently active in the cluster pub fn versions(&self) -> &[LayoutVersion] { &self.inner().versions } + /// Returns the latest layout version for which it is safe to read data from, + /// i.e. the version whose version number is sync_map_min + pub fn read_version(&self) -> &LayoutVersion { + let sync_min = self.sync_map_min; + self.versions() + .iter() + .find(|x| x.version == sync_min) + .or(self.versions().last()) + .unwrap() + } + pub fn is_check_ok(&self) -> bool { self.is_check_ok } @@ -173,6 +186,16 @@ impl LayoutHelper { &self.all_nongateway_nodes } + /// Returns the set of nodes for storing this hash in the current layout version. + /// + /// Used by the block maanger only: data blocks are immutable, so we don't have + /// to coordinate between old set of nodes and new set of nodes when layout changes. + /// As soon as the layout change is effective, blocks can be moved to the new + /// set of nodes. + pub fn current_storage_nodes_of(&self, hash: &Hash) -> Vec { + self.current().nodes_of(hash).collect() + } + pub fn ack_map_min(&self) -> u64 { self.ack_map_min } @@ -181,6 +204,8 @@ impl LayoutHelper { self.sync_map_min } + // ---- helpers for layout synchronization ---- + pub fn sync_digest(&self) -> SyncLayoutDigest { SyncLayoutDigest { current: self.current().version, @@ -189,50 +214,7 @@ impl LayoutHelper { } } - pub fn read_nodes_of(&self, position: &Hash) -> Vec { - let sync_min = self.sync_map_min; - let version = self - .versions() - .iter() - .find(|x| x.version == sync_min) - .or(self.versions().last()) - .unwrap(); - version - .nodes_of(position, version.replication_factor) - .collect() - } - - pub fn storage_sets_of(&self, position: &Hash) -> Vec> { - self.versions() - .iter() - .map(|x| x.nodes_of(position, x.replication_factor).collect()) - .collect() - } - - pub fn storage_nodes_of(&self, position: &Hash) -> Vec { - let mut ret = vec![]; - for version in self.versions().iter() { - ret.extend(version.nodes_of(position, version.replication_factor)); - } - ret.sort(); - ret.dedup(); - ret - } - - pub fn current_storage_nodes_of(&self, position: &Hash) -> Vec { - let ver = self.current(); - ver.nodes_of(position, ver.replication_factor).collect() - } - - pub fn trackers_hash(&self) -> Hash { - self.trackers_hash - } - - pub fn staging_hash(&self) -> Hash { - self.staging_hash - } - - pub fn digest(&self) -> RpcLayoutDigest { + pub(crate) fn digest(&self) -> RpcLayoutDigest { RpcLayoutDigest { current_version: self.current().version, active_versions: self.versions().len(), diff --git a/src/rpc/layout/history.rs b/src/rpc/layout/history.rs index 16c32fb2..1e6bc84b 100644 --- a/src/rpc/layout/history.rs +++ b/src/rpc/layout/history.rs @@ -180,9 +180,7 @@ impl LayoutHistory { // Determine set of nodes for partition p in layout version v. // Sort the node set to avoid duplicate computations. - let mut set = v - .nodes_of(&p_hash, v.replication_factor) - .collect::>(); + let mut set = v.nodes_of(&p_hash).collect::>(); set.sort(); // If this set was already processed, skip it. diff --git a/src/rpc/layout/manager.rs b/src/rpc/layout/manager.rs index 21907ec7..55b67a27 100644 --- a/src/rpc/layout/manager.rs +++ b/src/rpc/layout/manager.rs @@ -143,10 +143,14 @@ impl LayoutManager { // ---- ACK LOCKING ---- - pub fn write_sets_of(self: &Arc, position: &Hash) -> WriteLock>> { + pub fn write_sets_of(self: &Arc, hash: &Hash) -> WriteLock>> { let layout = self.layout(); let version = layout.current().version; - let nodes = layout.storage_sets_of(position); + let nodes = layout + .versions() + .iter() + .map(|x| x.nodes_of(hash).collect()) + .collect(); layout .ack_lock .get(&version) diff --git a/src/rpc/layout/version.rs b/src/rpc/layout/version.rs index 90a51de7..fdcccc46 100644 --- a/src/rpc/layout/version.rs +++ b/src/rpc/layout/version.rs @@ -114,9 +114,7 @@ impl LayoutVersion { } /// Return the n servers in which data for this hash should be replicated - pub fn nodes_of(&self, position: &Hash, n: usize) -> impl Iterator + '_ { - assert_eq!(n, self.replication_factor); - + pub fn nodes_of(&self, position: &Hash) -> impl Iterator + '_ { let data = &self.ring_assignment_data; let partition_nodes = if data.len() == self.replication_factor * (1 << PARTITION_BITS) { diff --git a/src/rpc/rpc_helper.rs b/src/rpc/rpc_helper.rs index 2505c2ce..87fff5d6 100644 --- a/src/rpc/rpc_helper.rs +++ b/src/rpc/rpc_helper.rs @@ -573,7 +573,7 @@ impl RpcHelper { // Compute, for each layout version, the set of nodes that might store // the block, and put them in their preferred order as of `request_order`. let mut vernodes = layout.versions().iter().map(|ver| { - let nodes = ver.nodes_of(position, ver.replication_factor); + let nodes = ver.nodes_of(position); rpc_helper.request_order(layout.current(), nodes) }); @@ -607,7 +607,7 @@ impl RpcHelper { // Second step: add nodes of older layout versions let old_ver_iter = layout.inner().old_versions.iter().rev(); for ver in old_ver_iter { - let nodes = ver.nodes_of(position, ver.replication_factor); + let nodes = ver.nodes_of(position); for node in rpc_helper.request_order(layout.current(), nodes) { if !ret.contains(&node) { ret.push(node); diff --git a/src/rpc/system.rs b/src/rpc/system.rs index 198a5f6b..800b37f3 100644 --- a/src/rpc/system.rs +++ b/src/rpc/system.rs @@ -475,10 +475,7 @@ impl System { let mut partitions_quorum = 0; let mut partitions_all_ok = 0; for (_, hash) in partitions.iter() { - let mut write_sets = layout - .versions() - .iter() - .map(|x| x.nodes_of(hash, x.replication_factor)); + let mut write_sets = layout.versions().iter().map(|x| x.nodes_of(hash)); let has_quorum = write_sets .clone() .all(|set| set.filter(|x| node_up(x)).count() >= quorum); diff --git a/src/table/replication/sharded.rs b/src/table/replication/sharded.rs index e0245949..17a848fe 100644 --- a/src/table/replication/sharded.rs +++ b/src/table/replication/sharded.rs @@ -28,11 +28,22 @@ impl TableReplication for TableShardedReplication { type WriteSets = WriteLock>>; fn storage_nodes(&self, hash: &Hash) -> Vec { - self.system.cluster_layout().storage_nodes_of(hash) + let layout = self.system.cluster_layout(); + let mut ret = vec![]; + for version in layout.versions().iter() { + ret.extend(version.nodes_of(hash)); + } + ret.sort(); + ret.dedup(); + ret } fn read_nodes(&self, hash: &Hash) -> Vec { - self.system.cluster_layout().read_nodes_of(hash) + self.system + .cluster_layout() + .read_version() + .nodes_of(hash) + .collect() } fn read_quorum(&self) -> usize { self.read_quorum @@ -57,7 +68,11 @@ impl TableReplication for TableShardedReplication { .current() .partitions() .map(|(partition, first_hash)| { - let storage_sets = layout.storage_sets_of(&first_hash); + let storage_sets = layout + .versions() + .iter() + .map(|x| x.nodes_of(&first_hash).collect()) + .collect(); SyncPartition { partition, first_hash, From 34baade4997b58029a0569324eef728340bb0fda Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 11:40:09 +0100 Subject: [PATCH 092/192] fullcopy replication: quorum reads and writes --- src/table/replication/fullcopy.rs | 60 ++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/table/replication/fullcopy.rs b/src/table/replication/fullcopy.rs index 39e29580..938fe954 100644 --- a/src/table/replication/fullcopy.rs +++ b/src/table/replication/fullcopy.rs @@ -27,26 +27,49 @@ impl TableReplication for TableFullReplication { type WriteSets = Vec>; fn storage_nodes(&self, _hash: &Hash) -> Vec { - let layout = self.system.cluster_layout(); - layout.current().all_nodes().to_vec() + self.system.cluster_layout().all_nodes().to_vec() } fn read_nodes(&self, _hash: &Hash) -> Vec { - vec![self.system.id] + self.system + .cluster_layout() + .read_version() + .all_nodes() + .to_vec() } fn read_quorum(&self) -> usize { - 1 + let layout = self.system.cluster_layout(); + let nodes = layout.read_version().all_nodes(); + nodes.len().div_euclid(2) + 1 } - fn write_sets(&self, hash: &Hash) -> Self::WriteSets { - vec![self.storage_nodes(hash)] + fn write_sets(&self, _hash: &Hash) -> Self::WriteSets { + self.system + .cluster_layout() + .versions() + .iter() + .map(|ver| ver.all_nodes().to_vec()) + .collect() } fn write_quorum(&self) -> usize { - let nmembers = self.system.cluster_layout().current().all_nodes().len(); - if nmembers < 3 { - 1 + let layout = self.system.cluster_layout(); + let min_len = layout + .versions() + .iter() + .map(|x| x.all_nodes().len()) + .min() + .unwrap(); + let max_quorum = layout + .versions() + .iter() + .map(|x| x.all_nodes().len().div_euclid(2) + 1) + .max() + .unwrap(); + if min_len < max_quorum { + warn!("Write quorum will not be respected for TableFullReplication operations due to multiple active layout versions with vastly different number of nodes"); + min_len } else { - nmembers.div_euclid(2) + 1 + max_quorum } } @@ -56,15 +79,18 @@ impl TableReplication for TableFullReplication { fn sync_partitions(&self) -> SyncPartitions { let layout = self.system.cluster_layout(); - let layout_version = layout.current().version; + let layout_version = layout.ack_map_min(); + + let partitions = vec![SyncPartition { + partition: 0u16, + first_hash: [0u8; 32].into(), + last_hash: [0xff; 32].into(), + storage_sets: self.write_sets(&[0u8; 32].into()), + }]; + SyncPartitions { layout_version, - partitions: vec![SyncPartition { - partition: 0u16, - first_hash: [0u8; 32].into(), - last_hash: [0xff; 32].into(), - storage_sets: vec![layout.current().all_nodes().to_vec()], - }], + partitions, } } } From 2c9e849bbfc4d79b767b8ae19dea45452bad5ddd Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 11:43:41 +0100 Subject: [PATCH 093/192] remove dependency from garage_block to garage_table --- Cargo.lock | 1 - src/block/Cargo.toml | 1 - src/block/manager.rs | 12 +++++------- src/block/resync.rs | 4 +--- src/model/garage.rs | 10 ++-------- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6032cdf8..1c66d7f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,7 +1404,6 @@ dependencies = [ "garage_db", "garage_net", "garage_rpc", - "garage_table", "garage_util", "hex", "opentelemetry", diff --git a/src/block/Cargo.toml b/src/block/Cargo.toml index 1f5558c5..dc13130b 100644 --- a/src/block/Cargo.toml +++ b/src/block/Cargo.toml @@ -18,7 +18,6 @@ garage_db.workspace = true garage_net.workspace = true garage_rpc.workspace = true garage_util.workspace = true -garage_table.workspace = true opentelemetry.workspace = true diff --git a/src/block/manager.rs b/src/block/manager.rs index 41b2f02a..1b84dd5a 100644 --- a/src/block/manager.rs +++ b/src/block/manager.rs @@ -33,8 +33,6 @@ use garage_rpc::rpc_helper::OrderTag; use garage_rpc::system::System; use garage_rpc::*; -use garage_table::replication::{TableReplication, TableShardedReplication}; - use crate::block::*; use crate::layout::*; use crate::metrics::*; @@ -74,8 +72,8 @@ impl Rpc for BlockRpc { /// The block manager, handling block exchange between nodes, and block storage on local node pub struct BlockManager { - /// Replication strategy, allowing to find on which node blocks should be located - pub replication: TableShardedReplication, + /// Quorum of nodes for write operations + pub write_quorum: usize, /// Data layout pub(crate) data_layout: ArcSwap, @@ -122,7 +120,7 @@ impl BlockManager { pub fn new( db: &db::Db, config: &Config, - replication: TableShardedReplication, + write_quorum: usize, system: Arc, ) -> Result, Error> { // Load or compute layout, i.e. assignment of data blocks to the different data directories @@ -166,7 +164,7 @@ impl BlockManager { let scrub_persister = PersisterShared::new(&system.metadata_dir, "scrub_info"); let block_manager = Arc::new(Self { - replication, + write_quorum, data_layout: ArcSwap::new(Arc::new(data_layout)), data_layout_persister, data_fsync: config.data_fsync, @@ -400,7 +398,7 @@ impl BlockManager { put_block_rpc, RequestStrategy::with_priority(PRIO_NORMAL | PRIO_SECONDARY) .with_drop_on_completion(permit) - .with_quorum(self.replication.write_quorum()), + .with_quorum(self.write_quorum), ) .await?; diff --git a/src/block/resync.rs b/src/block/resync.rs index b476a0b8..6fa4cc1a 100644 --- a/src/block/resync.rs +++ b/src/block/resync.rs @@ -27,8 +27,6 @@ use garage_util::tranquilizer::Tranquilizer; use garage_rpc::system::System; use garage_rpc::*; -use garage_table::replication::TableReplication; - use crate::manager::*; // The delay between the time where a resync operation fails @@ -381,7 +379,7 @@ impl BlockResyncManager { .system .cluster_layout() .current_storage_nodes_of(hash); - if who.len() < manager.replication.write_quorum() { + if who.len() < manager.write_quorum { return Err(Error::Message("Not trying to offload block because we don't have a quorum of nodes to write to".to_string())); } who.retain(|id| *id != manager.system.id); diff --git a/src/model/garage.rs b/src/model/garage.rs index 95f7b577..a7e0b62b 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -154,13 +154,6 @@ impl Garage { info!("Initialize membership management system..."); let system = System::new(network_key, replication_factor, consistency_mode, &config)?; - let data_rep_param = TableShardedReplication { - system: system.clone(), - replication_factor: replication_factor.into(), - write_quorum: replication_factor.write_quorum(consistency_mode), - read_quorum: 1, - }; - let meta_rep_param = TableShardedReplication { system: system.clone(), replication_factor: replication_factor.into(), @@ -173,7 +166,8 @@ impl Garage { }; info!("Initialize block manager..."); - let block_manager = BlockManager::new(&db, &config, data_rep_param, system.clone())?; + let block_write_quorum = replication_factor.write_quorum(consistency_mode); + let block_manager = BlockManager::new(&db, &config, block_write_quorum, system.clone())?; block_manager.register_bg_vars(&mut bg_vars); // ---- admin tables ---- From 1e13a66b421c1dad55b6a4f4e9675f3a949f9d0b Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 13:00:48 +0100 Subject: [PATCH 094/192] rework bucket helper functions to use local access where relevant --- src/api/admin/bucket.rs | 67 ++++++------ src/api/admin/special.rs | 12 +-- src/api/common/cors.rs | 18 +--- src/api/common/signature/mod.rs | 4 +- src/api/common/signature/payload.rs | 19 ++-- src/api/k2v/api_server.rs | 14 +-- src/api/k2v/error.rs | 2 +- src/api/s3/api_server.rs | 14 +-- src/api/s3/bucket.rs | 17 ++- src/api/s3/copy.rs | 13 ++- src/api/s3/post_object.rs | 12 +-- src/model/helper/bucket.rs | 156 +++++++++++++++++++++++----- src/model/helper/locked.rs | 17 +-- src/model/k2v/rpc.rs | 5 +- src/table/table.rs | 9 ++ 15 files changed, 228 insertions(+), 151 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 7f89d4b2..a91940d7 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -79,18 +79,24 @@ impl RequestHandler for GetBucketInfoRequest { garage: &Arc, _admin: &Admin, ) -> Result { - let bucket_id = match (self.id, self.global_alias, self.search) { - (Some(id), None, None) => parse_bucket_id(&id)?, - (None, Some(ga), None) => garage - .bucket_alias_table - .get(&EmptyKey, &ga) - .await? - .and_then(|x| *x.state.get()) - .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, + let bucket = match (self.id, self.global_alias, self.search) { + (Some(id), None, None) => { + let id = parse_bucket_id(&id)?; + garage.bucket_helper().get_existing_bucket(id).await? + } + (None, Some(ga), None) => { + let id = garage + .bucket_alias_table + .get(&EmptyKey, &ga) + .await? + .and_then(|x| *x.state.get()) + .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?; + garage.bucket_helper().get_existing_bucket(id).await? + } (None, None, Some(search)) => { let helper = garage.bucket_helper(); - if let Some(uuid) = helper.resolve_global_bucket_name(&search).await? { - uuid + if let Some(bucket) = helper.resolve_global_bucket(&search).await? { + bucket } else { let hexdec = if search.len() >= 2 { search @@ -124,7 +130,7 @@ impl RequestHandler for GetBucketInfoRequest { if candidates.is_empty() { return Err(Error::Common(CommonError::NoSuchBucket(search.clone()))); } else if candidates.len() == 1 { - candidates.into_iter().next().unwrap().id + candidates.into_iter().next().unwrap() } else { return Err(Error::bad_request(format!( "Several matching buckets: {}", @@ -140,23 +146,18 @@ impl RequestHandler for GetBucketInfoRequest { } }; - bucket_info_results(garage, bucket_id).await + bucket_info_results(garage, bucket).await } } async fn bucket_info_results( garage: &Arc, - bucket_id: Uuid, + bucket: Bucket, ) -> Result { - let bucket = garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - let counters = garage .object_counter_table .table - .get(&bucket_id, &EmptyKey) + .get(&bucket.id, &EmptyKey) .await? .map(|x| x.filtered_values(&garage.system.cluster_layout())) .unwrap_or_default(); @@ -164,7 +165,7 @@ async fn bucket_info_results( let mpu_counters = garage .mpu_counter_table .table - .get(&bucket_id, &EmptyKey) + .get(&bucket.id, &EmptyKey) .await? .map(|x| x.filtered_values(&garage.system.cluster_layout())) .unwrap_or_default(); @@ -336,7 +337,7 @@ impl RequestHandler for CreateBucketRequest { } Ok(CreateBucketResponse( - bucket_info_results(garage, bucket.id).await?, + bucket_info_results(garage, bucket).await?, )) } } @@ -444,7 +445,7 @@ impl RequestHandler for UpdateBucketRequest { garage.bucket_table.insert(&bucket).await?; Ok(UpdateBucketResponse( - bucket_info_results(garage, bucket_id).await?, + bucket_info_results(garage, bucket).await?, )) } } @@ -534,7 +535,7 @@ pub async fn handle_bucket_change_key_perm( .set_bucket_key_permissions(bucket.id, &key.key_id, perm) .await?; - bucket_info_results(garage, bucket.id).await + bucket_info_results(garage, bucket).await } // ---- BUCKET ALIASES ---- @@ -551,11 +552,11 @@ impl RequestHandler for AddBucketAliasRequest { let helper = garage.locked_helper().await; - match self.alias { + let bucket = match self.alias { BucketAliasEnum::Global { global_alias } => { helper .set_global_bucket_alias(bucket_id, &global_alias) - .await?; + .await? } BucketAliasEnum::Local { local_alias, @@ -563,12 +564,12 @@ impl RequestHandler for AddBucketAliasRequest { } => { helper .set_local_bucket_alias(bucket_id, &access_key_id, &local_alias) - .await?; + .await? } - } + }; Ok(AddBucketAliasResponse( - bucket_info_results(garage, bucket_id).await?, + bucket_info_results(garage, bucket).await?, )) } } @@ -585,11 +586,11 @@ impl RequestHandler for RemoveBucketAliasRequest { let helper = garage.locked_helper().await; - match self.alias { + let bucket = match self.alias { BucketAliasEnum::Global { global_alias } => { helper .unset_global_bucket_alias(bucket_id, &global_alias) - .await?; + .await? } BucketAliasEnum::Local { local_alias, @@ -597,12 +598,12 @@ impl RequestHandler for RemoveBucketAliasRequest { } => { helper .unset_local_bucket_alias(bucket_id, &access_key_id, &local_alias) - .await?; + .await? } - } + }; Ok(RemoveBucketAliasResponse( - bucket_info_results(garage, bucket_id).await?, + bucket_info_results(garage, bucket).await?, )) } } diff --git a/src/api/admin/special.rs b/src/api/admin/special.rs index 0ecf82bc..0a4e6705 100644 --- a/src/api/admin/special.rs +++ b/src/api/admin/special.rs @@ -151,12 +151,11 @@ async fn check_domain(garage: &Arc, domain: &str) -> Result (domain.to_string(), true) }; - let bucket_id = match garage + let bucket = match garage .bucket_helper() - .resolve_global_bucket_name(&bucket_name) - .await? + .resolve_global_bucket_fast(&bucket_name)? { - Some(bucket_id) => bucket_id, + Some(b) => b, None => return Ok(false), }; @@ -164,11 +163,6 @@ async fn check_domain(garage: &Arc, domain: &str) -> Result return Ok(true); } - let bucket = garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - let bucket_state = bucket.state.as_option().unwrap(); let bucket_website_config = bucket_state.website_config.get(); diff --git a/src/api/common/cors.rs b/src/api/common/cors.rs index 09b55c13..a0ba6e48 100644 --- a/src/api/common/cors.rs +++ b/src/api/common/cors.rs @@ -9,9 +9,7 @@ use hyper::{body::Body, body::Incoming as IncomingBody, Request, Response, Statu use garage_model::bucket_table::{BucketParams, CorsRule as GarageCorsRule}; use garage_model::garage::Garage; -use crate::common_error::{ - helper_error_as_internal, CommonError, OkOrBadRequest, OkOrInternalError, -}; +use crate::common_error::{CommonError, OkOrBadRequest, OkOrInternalError}; use crate::helpers::*; pub fn find_matching_cors_rule<'a, B>( @@ -76,7 +74,7 @@ pub fn add_cors_headers( Ok(()) } -pub async fn handle_options_api( +pub fn handle_options_api( garage: Arc, req: &Request, bucket_name: Option, @@ -93,16 +91,8 @@ pub async fn handle_options_api( // OPTIONS calls are not auhtenticated). if let Some(bn) = bucket_name { let helper = garage.bucket_helper(); - let bucket_id = helper - .resolve_global_bucket_name(&bn) - .await - .map_err(helper_error_as_internal)?; - if let Some(id) = bucket_id { - let bucket = garage - .bucket_helper() - .get_existing_bucket(id) - .await - .map_err(helper_error_as_internal)?; + let bucket_opt = helper.resolve_global_bucket_fast(&bn)?; + if let Some(bucket) = bucket_opt { let bucket_params = bucket.state.into_option().unwrap(); handle_options_for_bucket(req, &bucket_params) } else { diff --git a/src/api/common/signature/mod.rs b/src/api/common/signature/mod.rs index 50fbd304..6f1748c3 100644 --- a/src/api/common/signature/mod.rs +++ b/src/api/common/signature/mod.rs @@ -64,12 +64,12 @@ pub struct VerifiedRequest { pub content_sha256_header: ContentSha256Header, } -pub async fn verify_request( +pub fn verify_request( garage: &Garage, mut req: Request, service: &'static str, ) -> Result { - let checked_signature = payload::check_payload_signature(&garage, &mut req, service).await?; + let checked_signature = payload::check_payload_signature(&garage, &mut req, service)?; let request = streaming::parse_streaming_body( req, diff --git a/src/api/common/signature/payload.rs b/src/api/common/signature/payload.rs index 2d5f8603..8386607d 100644 --- a/src/api/common/signature/payload.rs +++ b/src/api/common/signature/payload.rs @@ -32,7 +32,7 @@ pub struct CheckedSignature { pub signature_header: Option, } -pub async fn check_payload_signature( +pub fn check_payload_signature( garage: &Garage, request: &mut Request, service: &'static str, @@ -43,9 +43,9 @@ pub async fn check_payload_signature( // We check for presigned-URL-style authentication first, because // the browser or something else could inject an Authorization header // that is totally unrelated to AWS signatures. - check_presigned_signature(garage, service, request, query).await + check_presigned_signature(garage, service, request, query) } else if request.headers().contains_key(AUTHORIZATION) { - check_standard_signature(garage, service, request, query).await + check_standard_signature(garage, service, request, query) } else { // Unsigned (anonymous) request let content_sha256 = request @@ -93,7 +93,7 @@ fn parse_x_amz_content_sha256(header: Option<&str>) -> Result, @@ -128,7 +128,7 @@ async fn check_standard_signature( trace!("canonical request:\n{}", canonical_request); trace!("string to sign:\n{}", string_to_sign); - let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?; + let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes())?; let content_sha256_header = parse_x_amz_content_sha256(Some(&authorization.content_sha256))?; @@ -139,7 +139,7 @@ async fn check_standard_signature( }) } -async fn check_presigned_signature( +fn check_presigned_signature( garage: &Garage, service: &'static str, request: &mut Request, @@ -178,7 +178,7 @@ async fn check_presigned_signature( trace!("canonical request (presigned url):\n{}", canonical_request); trace!("string to sign (presigned url):\n{}", string_to_sign); - let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?; + let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes())?; // In the page on presigned URLs, AWS specifies that if a signed query // parameter and a signed header of the same name have different values, @@ -378,7 +378,7 @@ pub fn parse_date(date: &str) -> Result, Error> { Ok(Utc.from_utc_datetime(&date)) } -pub async fn verify_v4( +pub fn verify_v4( garage: &Garage, service: &str, auth: &Authorization, @@ -391,8 +391,7 @@ pub async fn verify_v4( let key = garage .key_table - .get(&EmptyKey, &auth.key_id) - .await? + .get_local(&EmptyKey, &auth.key_id)? .filter(|k| !k.state.is_deleted()) .ok_or_else(|| Error::forbidden(format!("No such key: {}", &auth.key_id)))?; let key_p = key.params().unwrap(); diff --git a/src/api/k2v/api_server.rs b/src/api/k2v/api_server.rs index dfa22dd2..8ace37d4 100644 --- a/src/api/k2v/api_server.rs +++ b/src/api/k2v/api_server.rs @@ -77,25 +77,19 @@ impl ApiHandler for K2VApiServer { // The OPTIONS method is processed early, before we even check for an API key if let Endpoint::Options = endpoint { let options_res = handle_options_api(garage, &req, Some(bucket_name)) - .await .ok_or_bad_request("Error handling OPTIONS")?; return Ok(options_res.map(|_empty_body: EmptyBody| empty_body())); } - let verified_request = verify_request(&garage, req, "k2v").await?; + let verified_request = verify_request(&garage, req, "k2v")?; let req = verified_request.request; let api_key = verified_request.access_key; - let bucket_id = garage - .bucket_helper() - .resolve_bucket(&bucket_name, &api_key) - .await - .map_err(pass_helper_error)?; let bucket = garage .bucket_helper() - .get_existing_bucket(bucket_id) - .await - .map_err(helper_error_as_internal)?; + .resolve_bucket_fast(&bucket_name, &api_key) + .map_err(pass_helper_error)?; + let bucket_id = bucket.id; let bucket_params = bucket.state.into_option().unwrap(); let allowed = match endpoint.authorization_type() { diff --git a/src/api/k2v/error.rs b/src/api/k2v/error.rs index 257ff893..55737268 100644 --- a/src/api/k2v/error.rs +++ b/src/api/k2v/error.rs @@ -2,8 +2,8 @@ use err_derive::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; +pub(crate) use garage_api_common::common_error::pass_helper_error; use garage_api_common::common_error::{commonErrorDerivative, CommonError}; -pub(crate) use garage_api_common::common_error::{helper_error_as_internal, pass_helper_error}; pub use garage_api_common::common_error::{ CommonErrorDerivative, OkOrBadRequest, OkOrInternalError, }; diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index 4cca21ed..1c967d58 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -118,11 +118,11 @@ impl ApiHandler for S3ApiServer { return handle_post_object(garage, req, bucket_name.unwrap()).await; } if let Endpoint::Options = endpoint { - let options_res = handle_options_api(garage, &req, bucket_name).await?; + let options_res = handle_options_api(garage, &req, bucket_name)?; return Ok(options_res.map(|_empty_body: EmptyBody| empty_body())); } - let verified_request = verify_request(&garage, req, "s3").await?; + let verified_request = verify_request(&garage, req, "s3")?; let req = verified_request.request; let api_key = verified_request.access_key; @@ -140,15 +140,11 @@ impl ApiHandler for S3ApiServer { return handle_create_bucket(&garage, req, &api_key.key_id, bucket_name).await; } - let bucket_id = garage - .bucket_helper() - .resolve_bucket(&bucket_name, &api_key) - .await - .map_err(pass_helper_error)?; let bucket = garage .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; + .resolve_bucket_fast(&bucket_name, &api_key) + .map_err(pass_helper_error)?; + let bucket_id = bucket.id; let bucket_params = bucket.state.into_option().unwrap(); let allowed = match endpoint.authorization_type() { diff --git a/src/api/s3/bucket.rs b/src/api/s3/bucket.rs index 3a09e769..7b5d714f 100644 --- a/src/api/s3/bucket.rs +++ b/src/api/s3/bucket.rs @@ -143,21 +143,16 @@ pub async fn handle_create_bucket( let api_key = helper.key().get_existing_key(api_key_id).await?; let key_params = api_key.params().unwrap(); - let existing_bucket = if let Some(Some(bucket_id)) = key_params.local_aliases.get(&bucket_name) - { - Some(*bucket_id) - } else { - helper - .bucket() - .resolve_global_bucket_name(&bucket_name) - .await? - }; + let existing_bucket = helper + .bucket() + .resolve_bucket(&bucket_name, &api_key.key_id) + .await?; - if let Some(bucket_id) = existing_bucket { + if let Some(bucket) = existing_bucket { // Check we have write or owner permission on the bucket, // in that case it's fine, return 200 OK, bucket exists; // otherwise return a forbidden error. - let kp = api_key.bucket_permissions(&bucket_id); + let kp = api_key.bucket_permissions(&bucket.id); if !(kp.allow_write || kp.allow_owner) { return Err(CommonError::BucketAlreadyExists.into()); } diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index 7c67a65d..8892d4ff 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -683,16 +683,15 @@ async fn get_copy_source(ctx: &ReqCtx, req: &Request) -> Result) -> Result(pub(crate) &'a Garage); #[allow(clippy::ptr_arg)] impl<'a> BucketHelper<'a> { - pub async fn resolve_global_bucket_name( + // ================ + // Local functions to find buckets FAST. + // This is only for the fast path in API requests. + // They do not conserve the read-after-write guarantee. + // ================ + + /// Return bucket ID corresponding to global bucket name. + /// + /// The name can be of two forms: + /// 1. A global bucket alias + /// 2. The full ID of a bucket encoded in hex + /// + /// This will not do any network interaction to check the alias table, + /// it will only check the local copy of the table. + /// As a consequence, it does not conserve read-after-write guarantees. + pub fn resolve_global_bucket_fast( &self, bucket_name: &String, - ) -> Result, Error> { + ) -> Result, GarageError> { // Bucket names in Garage are aliases, true bucket identifiers // are 32-byte UUIDs. This function resolves bucket names into // their full identifier by looking up in the bucket_alias_table. @@ -32,38 +47,129 @@ impl<'a> BucketHelper<'a> { let hexbucket = hex::decode(bucket_name.as_str()) .ok() .and_then(|by| Uuid::try_from(&by)); - if let Some(bucket_id) = hexbucket { - Ok(self - .0 - .bucket_table - .get(&EmptyKey, &bucket_id) - .await? - .filter(|x| !x.state.is_deleted()) - .map(|_| bucket_id)) - } else { - Ok(self - .0 - .bucket_alias_table - .get(&EmptyKey, bucket_name) - .await? - .and_then(|x| *x.state.get())) - } + let bucket_id = match hexbucket { + Some(id) => id, + None => { + let alias = self + .0 + .bucket_alias_table + .get_local(&EmptyKey, bucket_name)? + .and_then(|x| *x.state.get()); + match alias { + Some(id) => id, + None => return Ok(None), + } + } + }; + Ok(self + .0 + .bucket_table + .get_local(&EmptyKey, &bucket_id)? + .filter(|x| !x.state.is_deleted())) } + /// Return bucket ID corresponding to a bucket name from the perspective of + /// a given access key. + /// + /// The name can be of three forms: + /// 1. A global bucket alias + /// 2. A local bucket alias + /// 3. The full ID of a bucket encoded in hex + /// + /// This will not do any network interaction to check the alias table, + /// it will only check the local copy of the table. + /// As a consequence, it does not conserve read-after-write guarantees. + /// + /// This function transforms non-existing buckets in a NoSuchBucket error. #[allow(clippy::ptr_arg)] - pub async fn resolve_bucket(&self, bucket_name: &String, api_key: &Key) -> Result { + pub fn resolve_bucket_fast( + &self, + bucket_name: &String, + api_key: &Key, + ) -> Result { let api_key_params = api_key .state .as_option() .ok_or_message("Key should not be deleted at this point")?; - if let Some(Some(bucket_id)) = api_key_params.local_aliases.get(bucket_name) { - Ok(*bucket_id) - } else { + let bucket_opt = + if let Some(Some(bucket_id)) = api_key_params.local_aliases.get(bucket_name) { + self.0 + .bucket_table + .get_local(&EmptyKey, &bucket_id)? + .filter(|x| !x.state.is_deleted()) + } else { + self.resolve_global_bucket_fast(bucket_name)? + }; + bucket_opt.ok_or_else(|| Error::NoSuchBucket(bucket_name.to_string())) + } + + // ================ + // Global functions that do quorum reads/writes, + // for admin operations. + // ================ + + /// See resolve_global_bucket_fast, + /// but this one does a quorum read to ensure consistency + pub async fn resolve_global_bucket( + &self, + bucket_name: &String, + ) -> Result, GarageError> { + let hexbucket = hex::decode(bucket_name.as_str()) + .ok() + .and_then(|by| Uuid::try_from(&by)); + let bucket_id = match hexbucket { + Some(id) => id, + None => { + let alias = self + .0 + .bucket_alias_table + .get(&EmptyKey, bucket_name) + .await? + .and_then(|x| *x.state.get()); + match alias { + Some(id) => id, + None => return Ok(None), + } + } + }; + Ok(self + .0 + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .filter(|x| !x.state.is_deleted())) + } + + /// See resolve_bucket_fast, but this one does a quorum read to ensure consistency. + /// Also, this function does not return a HelperError::NoSuchBucket if bucket is absent. + #[allow(clippy::ptr_arg)] + pub async fn resolve_bucket( + &self, + bucket_name: &String, + key_id: &String, + ) -> Result, GarageError> { + let local_alias = self + .0 + .key_table + .get(&EmptyKey, &key_id) + .await? + .and_then(|k| k.state.into_option()) + .ok_or_else(|| GarageError::Message(format!("access key {} has been deleted", key_id)))? + .local_aliases + .get(bucket_name) + .copied() + .flatten(); + + if let Some(bucket_id) = local_alias { Ok(self - .resolve_global_bucket_name(bucket_name) + .0 + .bucket_table + .get(&EmptyKey, &bucket_id) .await? - .ok_or_else(|| Error::NoSuchBucket(bucket_name.to_string()))?) + .filter(|x| !x.state.is_deleted())) + } else { + Ok(self.resolve_global_bucket(bucket_name).await?) } } diff --git a/src/model/helper/locked.rs b/src/model/helper/locked.rs index 482e91b0..9d6a8d36 100644 --- a/src/model/helper/locked.rs +++ b/src/model/helper/locked.rs @@ -6,6 +6,7 @@ use garage_util::time::*; use garage_table::util::*; use crate::bucket_alias_table::*; +use crate::bucket_table::*; use crate::garage::Garage; use crate::helper::bucket::BucketHelper; use crate::helper::error::*; @@ -56,7 +57,7 @@ impl<'a> LockedHelper<'a> { &self, bucket_id: Uuid, alias_name: &String, - ) -> Result<(), Error> { + ) -> Result { if !is_valid_bucket_name(alias_name) { return Err(Error::InvalidBucketName(alias_name.to_string())); } @@ -100,7 +101,7 @@ impl<'a> LockedHelper<'a> { bucket_p.aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, true); self.0.bucket_table.insert(&bucket).await?; - Ok(()) + Ok(bucket) } /// Unsets an alias for a bucket in global namespace. @@ -112,7 +113,7 @@ impl<'a> LockedHelper<'a> { &self, bucket_id: Uuid, alias_name: &String, - ) -> Result<(), Error> { + ) -> Result { let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?; let bucket_state = bucket.state.as_option_mut().unwrap(); @@ -156,7 +157,7 @@ impl<'a> LockedHelper<'a> { bucket_state.aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, false); self.0.bucket_table.insert(&bucket).await?; - Ok(()) + Ok(bucket) } /// Ensures a bucket does not have a certain global alias. @@ -215,7 +216,7 @@ impl<'a> LockedHelper<'a> { bucket_id: Uuid, key_id: &String, alias_name: &String, - ) -> Result<(), Error> { + ) -> Result { let key_helper = KeyHelper(self.0); if !is_valid_bucket_name(alias_name) { @@ -257,7 +258,7 @@ impl<'a> LockedHelper<'a> { bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, true); self.0.bucket_table.insert(&bucket).await?; - Ok(()) + Ok(bucket) } /// Unsets an alias for a bucket in the local namespace of a key. @@ -271,7 +272,7 @@ impl<'a> LockedHelper<'a> { bucket_id: Uuid, key_id: &String, alias_name: &String, - ) -> Result<(), Error> { + ) -> Result { let key_helper = KeyHelper(self.0); let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?; @@ -330,7 +331,7 @@ impl<'a> LockedHelper<'a> { bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false); self.0.bucket_table.insert(&bucket).await?; - Ok(()) + Ok(bucket) } /// Sets permissions for a key on a bucket. diff --git a/src/model/k2v/rpc.rs b/src/model/k2v/rpc.rs index 821f4549..ddc356b5 100644 --- a/src/model/k2v/rpc.rs +++ b/src/model/k2v/rpc.rs @@ -451,10 +451,7 @@ impl K2VRpcHandler { let mut value = self .item_table - .data - .read_entry(&key.partition, &key.sort_key)? - .map(|bytes| self.item_table.data.decode_entry(&bytes[..])) - .transpose()? + .get_local(&key.partition, &key.sort_key)? .unwrap_or_else(|| { K2VItem::new( key.partition.bucket_id, diff --git a/src/table/table.rs b/src/table/table.rs index c96f4731..565d27a5 100644 --- a/src/table/table.rs +++ b/src/table/table.rs @@ -482,6 +482,15 @@ impl Table { Ok(ret_vec) } + pub fn get_local( + self: &Arc, + partition_key: &F::P, + sort_key: &F::S, + ) -> Result, Error> { + let bytes = self.data.read_entry(partition_key, sort_key)?; + bytes.map(|b| self.data.decode_entry(&b)).transpose() + } + // =============== UTILITY FUNCTION FOR CLIENT OPERATIONS =============== async fn repair_on_read(&self, who: &[Uuid], what: F::E) -> Result<(), Error> { From 9dcc5232a6cc9cf58b1e17c02120bc782274f9f2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 13:07:45 +0100 Subject: [PATCH 095/192] admin api: use fast local reads for token verification --- src/api/admin/api_server.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index a214dfa7..97b1fe0d 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -169,8 +169,7 @@ impl AdminApiServer { }; if token_required { - verify_authorization(&self.garage, global_token_hash, auth_header, request.name()) - .await?; + verify_authorization(&self.garage, global_token_hash, auth_header, request.name())?; } match request { @@ -245,7 +244,7 @@ fn hash_bearer_token(token: &str) -> String { .to_string() } -async fn verify_authorization( +fn verify_authorization( garage: &Garage, global_token_hash: Option<&str>, auth_header: Option, @@ -271,8 +270,7 @@ async fn verify_authorization( let token_hash_string = if let Some((prefix, _)) = token.split_once('.') { garage .admin_token_table - .get(&EmptyKey, &prefix.to_string()) - .await? + .get_local(&EmptyKey, &prefix.to_string())? .and_then(|k| k.state.into_option()) .filter(|p| { p.expiration From 8ba6454e21834061cdc90986e71690d64cbabbe0 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 13:11:11 +0100 Subject: [PATCH 096/192] reduce anti-entropy interval for fullcopy tables --- src/table/replication/fullcopy.rs | 7 +++++++ src/table/replication/parameters.rs | 4 ++++ src/table/replication/sharded.rs | 4 ++++ src/table/sync.rs | 5 +---- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/table/replication/fullcopy.rs b/src/table/replication/fullcopy.rs index 938fe954..63ca5181 100644 --- a/src/table/replication/fullcopy.rs +++ b/src/table/replication/fullcopy.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Duration; use garage_rpc::layout::*; use garage_rpc::system::System; @@ -26,6 +27,12 @@ pub struct TableFullReplication { impl TableReplication for TableFullReplication { type WriteSets = Vec>; + // Do anti-entropy every 10 seconds. + // Compared to sharded tables, anti-entropy is much less costly as there is + // a single partition hash to exchange. + // Also, it's generally a much bigger problem for fullcopy tables to be out of sync. + const ANTI_ENTROPY_INTERVAL: Duration = Duration::from_secs(10); + fn storage_nodes(&self, _hash: &Hash) -> Vec { self.system.cluster_layout().all_nodes().to_vec() } diff --git a/src/table/replication/parameters.rs b/src/table/replication/parameters.rs index 3649fad3..327f2cbf 100644 --- a/src/table/replication/parameters.rs +++ b/src/table/replication/parameters.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use garage_rpc::layout::*; use garage_util::data::*; @@ -5,6 +7,8 @@ use garage_util::data::*; pub trait TableReplication: Send + Sync + 'static { type WriteSets: AsRef>> + AsMut>> + Send + Sync + 'static; + const ANTI_ENTROPY_INTERVAL: Duration; + // See examples in table_sharded.rs and table_fullcopy.rs // To understand various replication methods diff --git a/src/table/replication/sharded.rs b/src/table/replication/sharded.rs index 17a848fe..e1041174 100644 --- a/src/table/replication/sharded.rs +++ b/src/table/replication/sharded.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Duration; use garage_rpc::layout::*; use garage_rpc::system::System; @@ -25,6 +26,9 @@ pub struct TableShardedReplication { } impl TableReplication for TableShardedReplication { + // Do anti-entropy every 10 minutes + const ANTI_ENTROPY_INTERVAL: Duration = Duration::from_secs(10 * 60); + type WriteSets = WriteLock>>; fn storage_nodes(&self, hash: &Hash) -> Vec { diff --git a/src/table/sync.rs b/src/table/sync.rs index 2d43b9fc..6c8ddb1d 100644 --- a/src/table/sync.rs +++ b/src/table/sync.rs @@ -27,9 +27,6 @@ use crate::merkle::*; use crate::replication::*; use crate::*; -// Do anti-entropy every 10 minutes -const ANTI_ENTROPY_INTERVAL: Duration = Duration::from_secs(10 * 60); - pub struct TableSyncer { system: Arc, data: Arc>, @@ -514,7 +511,7 @@ impl SyncWorker { partitions.partitions.shuffle(&mut thread_rng()); self.todo = Some(partitions); - self.next_full_sync = Instant::now() + ANTI_ENTROPY_INTERVAL; + self.next_full_sync = Instant::now() + R::ANTI_ENTROPY_INTERVAL; } } From 514eb298744d680949cbb6819ddaf0f4bad56098 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 13:18:14 +0100 Subject: [PATCH 097/192] use a WriteLock for write operations on fullcopy tables --- src/rpc/layout/helper.rs | 13 +++++++++++++ src/rpc/layout/manager.rs | 13 ++++++------- src/table/replication/fullcopy.rs | 11 ++++------- src/table/replication/sharded.rs | 11 ++++------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/rpc/layout/helper.rs b/src/rpc/layout/helper.rs index 482a2eea..6614ac22 100644 --- a/src/rpc/layout/helper.rs +++ b/src/rpc/layout/helper.rs @@ -196,6 +196,19 @@ impl LayoutHelper { self.current().nodes_of(hash).collect() } + /// For a given hash, or for all cluster if no hash is given, + /// return for each layout version the set of nodes that writes should be sent to + /// and for which a quorum of OK responses should be awaited. + pub fn write_sets_of(&self, hash: Option<&Hash>) -> Vec> { + self.versions() + .iter() + .map(|x| match hash { + Some(h) => x.nodes_of(h).collect(), + None => x.all_nodes().to_vec(), + }) + .collect() + } + pub fn ack_map_min(&self) -> u64 { self.ack_map_min } diff --git a/src/rpc/layout/manager.rs b/src/rpc/layout/manager.rs index 55b67a27..0c75742b 100644 --- a/src/rpc/layout/manager.rs +++ b/src/rpc/layout/manager.rs @@ -143,20 +143,19 @@ impl LayoutManager { // ---- ACK LOCKING ---- - pub fn write_sets_of(self: &Arc, hash: &Hash) -> WriteLock>> { + pub fn write_lock_with(self: &Arc, f: F) -> WriteLock + where + F: FnOnce(&LayoutHelper) -> T, + { let layout = self.layout(); let version = layout.current().version; - let nodes = layout - .versions() - .iter() - .map(|x| x.nodes_of(hash).collect()) - .collect(); + let value = f(&layout); layout .ack_lock .get(&version) .unwrap() .fetch_add(1, Ordering::Relaxed); - WriteLock::new(version, self, nodes) + WriteLock::new(version, self, value) } // ---- INTERNALS --- diff --git a/src/table/replication/fullcopy.rs b/src/table/replication/fullcopy.rs index 63ca5181..cdb7361f 100644 --- a/src/table/replication/fullcopy.rs +++ b/src/table/replication/fullcopy.rs @@ -25,7 +25,7 @@ pub struct TableFullReplication { } impl TableReplication for TableFullReplication { - type WriteSets = Vec>; + type WriteSets = WriteLock>>; // Do anti-entropy every 10 seconds. // Compared to sharded tables, anti-entropy is much less costly as there is @@ -52,11 +52,8 @@ impl TableReplication for TableFullReplication { fn write_sets(&self, _hash: &Hash) -> Self::WriteSets { self.system - .cluster_layout() - .versions() - .iter() - .map(|ver| ver.all_nodes().to_vec()) - .collect() + .layout_manager + .write_lock_with(|l| l.write_sets_of(None)) } fn write_quorum(&self) -> usize { let layout = self.system.cluster_layout(); @@ -92,7 +89,7 @@ impl TableReplication for TableFullReplication { partition: 0u16, first_hash: [0u8; 32].into(), last_hash: [0xff; 32].into(), - storage_sets: self.write_sets(&[0u8; 32].into()), + storage_sets: layout.write_sets_of(None), }]; SyncPartitions { diff --git a/src/table/replication/sharded.rs b/src/table/replication/sharded.rs index e1041174..4f73b277 100644 --- a/src/table/replication/sharded.rs +++ b/src/table/replication/sharded.rs @@ -54,7 +54,9 @@ impl TableReplication for TableShardedReplication { } fn write_sets(&self, hash: &Hash) -> Self::WriteSets { - self.system.layout_manager.write_sets_of(hash) + self.system + .layout_manager + .write_lock_with(|l| l.write_sets_of(Some(hash))) } fn write_quorum(&self) -> usize { self.write_quorum @@ -72,16 +74,11 @@ impl TableReplication for TableShardedReplication { .current() .partitions() .map(|(partition, first_hash)| { - let storage_sets = layout - .versions() - .iter() - .map(|x| x.nodes_of(&first_hash).collect()) - .collect(); SyncPartition { partition, first_hash, last_hash: [0u8; 32].into(), // filled in just after - storage_sets, + storage_sets: layout.write_sets_of(Some(&first_hash)), } }) .collect::>(); From d25e631a4a47ab54684777120316cdc32d49c4d5 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 16:35:56 +0100 Subject: [PATCH 098/192] relocalize logic for write_sets --- src/rpc/layout/helper.rs | 13 ------------- src/table/replication/fullcopy.rs | 14 ++++++++++---- src/table/replication/sharded.rs | 12 ++++++++++-- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/rpc/layout/helper.rs b/src/rpc/layout/helper.rs index 6614ac22..482a2eea 100644 --- a/src/rpc/layout/helper.rs +++ b/src/rpc/layout/helper.rs @@ -196,19 +196,6 @@ impl LayoutHelper { self.current().nodes_of(hash).collect() } - /// For a given hash, or for all cluster if no hash is given, - /// return for each layout version the set of nodes that writes should be sent to - /// and for which a quorum of OK responses should be awaited. - pub fn write_sets_of(&self, hash: Option<&Hash>) -> Vec> { - self.versions() - .iter() - .map(|x| match hash { - Some(h) => x.nodes_of(h).collect(), - None => x.all_nodes().to_vec(), - }) - .collect() - } - pub fn ack_map_min(&self) -> u64 { self.ack_map_min } diff --git a/src/table/replication/fullcopy.rs b/src/table/replication/fullcopy.rs index cdb7361f..2ede578e 100644 --- a/src/table/replication/fullcopy.rs +++ b/src/table/replication/fullcopy.rs @@ -51,9 +51,7 @@ impl TableReplication for TableFullReplication { } fn write_sets(&self, _hash: &Hash) -> Self::WriteSets { - self.system - .layout_manager - .write_lock_with(|l| l.write_sets_of(None)) + self.system.layout_manager.write_lock_with(write_sets) } fn write_quorum(&self) -> usize { let layout = self.system.cluster_layout(); @@ -89,7 +87,7 @@ impl TableReplication for TableFullReplication { partition: 0u16, first_hash: [0u8; 32].into(), last_hash: [0xff; 32].into(), - storage_sets: layout.write_sets_of(None), + storage_sets: write_sets(&layout), }]; SyncPartitions { @@ -98,3 +96,11 @@ impl TableReplication for TableFullReplication { } } } + +fn write_sets(layout: &LayoutHelper) -> Vec> { + layout + .versions() + .iter() + .map(|x| x.all_nodes().to_vec()) + .collect() +} diff --git a/src/table/replication/sharded.rs b/src/table/replication/sharded.rs index 4f73b277..2514d880 100644 --- a/src/table/replication/sharded.rs +++ b/src/table/replication/sharded.rs @@ -56,7 +56,7 @@ impl TableReplication for TableShardedReplication { fn write_sets(&self, hash: &Hash) -> Self::WriteSets { self.system .layout_manager - .write_lock_with(|l| l.write_sets_of(Some(hash))) + .write_lock_with(|l| write_sets(l, hash)) } fn write_quorum(&self) -> usize { self.write_quorum @@ -78,7 +78,7 @@ impl TableReplication for TableShardedReplication { partition, first_hash, last_hash: [0u8; 32].into(), // filled in just after - storage_sets: layout.write_sets_of(Some(&first_hash)), + storage_sets: write_sets(&layout, &first_hash), } }) .collect::>(); @@ -97,3 +97,11 @@ impl TableReplication for TableShardedReplication { } } } + +fn write_sets(layout: &LayoutHelper, hash: &Hash) -> Vec> { + layout + .versions() + .iter() + .map(|x| x.nodes_of(hash).collect()) + .collect() +} From c6bed263472805433b74472cba76adeb7c3be523 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 16:41:19 +0100 Subject: [PATCH 099/192] relocalize logic into block manager --- src/block/manager.rs | 15 ++++++++++++++- src/block/resync.rs | 10 ++-------- src/rpc/layout/helper.rs | 10 ---------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/block/manager.rs b/src/block/manager.rs index 1b84dd5a..bd0b7611 100644 --- a/src/block/manager.rs +++ b/src/block/manager.rs @@ -336,6 +336,19 @@ impl BlockManager { Err(err) } + /// Returns the set of nodes that should store a copy of a given block. + /// These are the nodes assigned to the block's hash in the current + /// layout version only: since blocks are immutable, we don't need to + /// do complex logic when several layour versions are active at once, + /// just move them directly to the new nodes. + pub(crate) fn storage_nodes_of(&self, hash: &Hash) -> Vec { + self.system + .cluster_layout() + .current() + .nodes_of(hash) + .collect() + } + // ---- Public interface ---- /// Ask nodes that might have a block for it, return it as a stream @@ -368,7 +381,7 @@ impl BlockManager { prevent_compression: bool, order_tag: Option, ) -> Result<(), Error> { - let who = self.system.cluster_layout().current_storage_nodes_of(&hash); + let who = self.storage_nodes_of(&hash); let compression_level = self.compression_level.filter(|_| !prevent_compression); let (header, bytes) = DataBlock::from_buffer(data, compression_level) diff --git a/src/block/resync.rs b/src/block/resync.rs index 6fa4cc1a..307f7c48 100644 --- a/src/block/resync.rs +++ b/src/block/resync.rs @@ -375,10 +375,7 @@ impl BlockResyncManager { info!("Resync block {:?}: offloading and deleting", hash); let existing_path = existing_path.unwrap(); - let mut who = manager - .system - .cluster_layout() - .current_storage_nodes_of(hash); + let mut who = manager.storage_nodes_of(hash); if who.len() < manager.write_quorum { return Err(Error::Message("Not trying to offload block because we don't have a quorum of nodes to write to".to_string())); } @@ -461,10 +458,7 @@ impl BlockResyncManager { // First, check whether we are still supposed to store that // block in the latest cluster layout version. - let storage_nodes = manager - .system - .cluster_layout() - .current_storage_nodes_of(&hash); + let storage_nodes = manager.storage_nodes_of(&hash); if !storage_nodes.contains(&manager.system.id) { info!( diff --git a/src/rpc/layout/helper.rs b/src/rpc/layout/helper.rs index 482a2eea..35746851 100644 --- a/src/rpc/layout/helper.rs +++ b/src/rpc/layout/helper.rs @@ -186,16 +186,6 @@ impl LayoutHelper { &self.all_nongateway_nodes } - /// Returns the set of nodes for storing this hash in the current layout version. - /// - /// Used by the block maanger only: data blocks are immutable, so we don't have - /// to coordinate between old set of nodes and new set of nodes when layout changes. - /// As soon as the layout change is effective, blocks can be moved to the new - /// set of nodes. - pub fn current_storage_nodes_of(&self, hash: &Hash) -> Vec { - self.current().nodes_of(hash).collect() - } - pub fn ack_map_min(&self) -> u64 { self.ack_map_min } From 5fa6df6ee393376ac22d1f791ba9a34ae0e27341 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 25 Mar 2025 16:58:12 +0100 Subject: [PATCH 100/192] improve comments in bucket helper --- src/model/helper/bucket.rs | 55 +++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/model/helper/bucket.rs b/src/model/helper/bucket.rs index b47b104e..ebbe95ca 100644 --- a/src/model/helper/bucket.rs +++ b/src/model/helper/bucket.rs @@ -19,31 +19,29 @@ impl<'a> BucketHelper<'a> { // ================ // Local functions to find buckets FAST. // This is only for the fast path in API requests. - // They do not conserve the read-after-write guarantee. + // They do not provide the read-after-write guarantee + // when used in conjunction with other operations that + // modify buckets and bucket aliases. // ================ - /// Return bucket ID corresponding to global bucket name. + /// Return bucket corresponding to global bucket name, if it exists + /// (and is not a tombstone entry). /// /// The name can be of two forms: /// 1. A global bucket alias /// 2. The full ID of a bucket encoded in hex /// - /// This will not do any network interaction to check the alias table, - /// it will only check the local copy of the table. - /// As a consequence, it does not conserve read-after-write guarantees. + /// Note that there is no possible ambiguity between the two forms, + /// as the maximum length of a bucket name is 63 characters, and the full + /// hex id is 64 chars long. + /// + /// This will not do any network interaction to check the alias and + /// bucket tables, it will only check the local copy of the table. + /// As a consequence, it does not provide read-after-write guarantees. pub fn resolve_global_bucket_fast( &self, bucket_name: &String, ) -> Result, GarageError> { - // Bucket names in Garage are aliases, true bucket identifiers - // are 32-byte UUIDs. This function resolves bucket names into - // their full identifier by looking up in the bucket_alias_table. - // This function also allows buckets to be identified by their - // full UUID (hex-encoded). Here, if the name to be resolved is a - // hex string of the correct length, it is directly parsed as a bucket - // identifier which is returned. There is no risk of this conflicting - // with an actual bucket name: bucket names are max 63 chars long by - // the AWS spec, and hex-encoded UUIDs are 64 chars long. let hexbucket = hex::decode(bucket_name.as_str()) .ok() .and_then(|by| Uuid::try_from(&by)); @@ -68,19 +66,20 @@ impl<'a> BucketHelper<'a> { .filter(|x| !x.state.is_deleted())) } - /// Return bucket ID corresponding to a bucket name from the perspective of - /// a given access key. + /// Return bucket corresponding to a bucket name from the perspective of + /// a given access key, if it exists (and is not a tombstone entry). /// /// The name can be of three forms: /// 1. A global bucket alias /// 2. A local bucket alias /// 3. The full ID of a bucket encoded in hex /// - /// This will not do any network interaction to check the alias table, - /// it will only check the local copy of the table. - /// As a consequence, it does not conserve read-after-write guarantees. + /// This will not do any network interaction, it will only check the local + /// copy of the bucket and global alias table. It will also resolve local + /// aliases directly using the data provided in the `api_key` parameter. + /// As a consequence, it does not provide read-after-write guarantees. /// - /// This function transforms non-existing buckets in a NoSuchBucket error. + /// In case no such bucket is found, this function returns a NoSuchBucket error. #[allow(clippy::ptr_arg)] pub fn resolve_bucket_fast( &self, @@ -109,8 +108,8 @@ impl<'a> BucketHelper<'a> { // for admin operations. // ================ - /// See resolve_global_bucket_fast, - /// but this one does a quorum read to ensure consistency + /// This is the same as `resolve_global_bucket_fast`, + /// except that it does quorum reads to ensure consistency. pub async fn resolve_global_bucket( &self, bucket_name: &String, @@ -141,8 +140,14 @@ impl<'a> BucketHelper<'a> { .filter(|x| !x.state.is_deleted())) } - /// See resolve_bucket_fast, but this one does a quorum read to ensure consistency. - /// Also, this function does not return a HelperError::NoSuchBucket if bucket is absent. + /// Return bucket corresponding to a bucket name from the perspective of + /// a given access key, if it exists (and is not a tombstone entry). + /// + /// This is the same as `resolve_bucket_fast`, with the following differences: + /// + /// - this function does quorum reads to ensure consistency. + /// - this function fetches the Key entry from the key table to ensure up-to-date data + /// - this function returns None if the bucket is not found, instead of HelperError::NoSuchBucket #[allow(clippy::ptr_arg)] pub async fn resolve_bucket( &self, @@ -176,7 +181,7 @@ impl<'a> BucketHelper<'a> { /// Returns a Bucket if it is present in bucket table, /// even if it is in deleted state. Querying a non-existing /// bucket ID returns an internal error. - pub async fn get_internal_bucket(&self, bucket_id: Uuid) -> Result { + pub(crate) async fn get_internal_bucket(&self, bucket_id: Uuid) -> Result { Ok(self .0 .bucket_table From 6bbdca2e48d21fa94f9c97dc82ad79fe14b9392d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 6 Apr 2025 11:14:42 +0200 Subject: [PATCH 101/192] admin api: always return latest bucket info --- src/api/admin/bucket.rs | 719 ++++++++++++++++++------------------- src/model/helper/locked.rs | 17 +- 2 files changed, 367 insertions(+), 369 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index a91940d7..ce12b4cf 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -79,24 +79,18 @@ impl RequestHandler for GetBucketInfoRequest { garage: &Arc, _admin: &Admin, ) -> Result { - let bucket = match (self.id, self.global_alias, self.search) { - (Some(id), None, None) => { - let id = parse_bucket_id(&id)?; - garage.bucket_helper().get_existing_bucket(id).await? - } - (None, Some(ga), None) => { - let id = garage - .bucket_alias_table - .get(&EmptyKey, &ga) - .await? - .and_then(|x| *x.state.get()) - .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?; - garage.bucket_helper().get_existing_bucket(id).await? - } + let bucket_id = match (self.id, self.global_alias, self.search) { + (Some(id), None, None) => parse_bucket_id(&id)?, + (None, Some(ga), None) => garage + .bucket_alias_table + .get(&EmptyKey, &ga) + .await? + .and_then(|x| *x.state.get()) + .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, (None, None, Some(search)) => { let helper = garage.bucket_helper(); if let Some(bucket) = helper.resolve_global_bucket(&search).await? { - bucket + bucket.id } else { let hexdec = if search.len() >= 2 { search @@ -130,7 +124,7 @@ impl RequestHandler for GetBucketInfoRequest { if candidates.is_empty() { return Err(Error::Common(CommonError::NoSuchBucket(search.clone()))); } else if candidates.len() == 1 { - candidates.into_iter().next().unwrap() + candidates.into_iter().next().unwrap().id } else { return Err(Error::bad_request(format!( "Several matching buckets: {}", @@ -146,14 +140,361 @@ impl RequestHandler for GetBucketInfoRequest { } }; - bucket_info_results(garage, bucket).await + bucket_info_results(garage, bucket_id).await } } +impl RequestHandler for CreateBucketRequest { + type Response = CreateBucketResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let helper = garage.locked_helper().await; + + if let Some(ga) = &self.global_alias { + if !is_valid_bucket_name(ga) { + return Err(Error::bad_request(format!( + "{}: {}", + ga, INVALID_BUCKET_NAME_MESSAGE + ))); + } + + if let Some(alias) = garage.bucket_alias_table.get(&EmptyKey, ga).await? { + if alias.state.get().is_some() { + return Err(CommonError::BucketAlreadyExists.into()); + } + } + } + + if let Some(la) = &self.local_alias { + if !is_valid_bucket_name(&la.alias) { + return Err(Error::bad_request(format!( + "{}: {}", + la.alias, INVALID_BUCKET_NAME_MESSAGE + ))); + } + + let key = helper.key().get_existing_key(&la.access_key_id).await?; + let state = key.state.as_option().unwrap(); + if matches!(state.local_aliases.get(&la.alias), Some(_)) { + return Err(Error::bad_request("Local alias already exists")); + } + } + + let bucket = Bucket::new(); + garage.bucket_table.insert(&bucket).await?; + + if let Some(ga) = &self.global_alias { + helper.set_global_bucket_alias(bucket.id, ga).await?; + } + + if let Some(la) = &self.local_alias { + helper + .set_local_bucket_alias(bucket.id, &la.access_key_id, &la.alias) + .await?; + + if la.allow.read || la.allow.write || la.allow.owner { + helper + .set_bucket_key_permissions( + bucket.id, + &la.access_key_id, + BucketKeyPerm { + timestamp: now_msec(), + allow_read: la.allow.read, + allow_write: la.allow.write, + allow_owner: la.allow.owner, + }, + ) + .await?; + } + } + + Ok(CreateBucketResponse( + bucket_info_results(garage, bucket.id).await?, + )) + } +} + +impl RequestHandler for DeleteBucketRequest { + type Response = DeleteBucketResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let helper = garage.locked_helper().await; + + let bucket_id = parse_bucket_id(&self.id)?; + + let mut bucket = helper.bucket().get_existing_bucket(bucket_id).await?; + let state = bucket.state.as_option().unwrap(); + + // Check bucket is empty + if !helper.bucket().is_bucket_empty(bucket_id).await? { + return Err(CommonError::BucketNotEmpty.into()); + } + + // --- done checking, now commit --- + // 1. delete authorization from keys that had access + for (key_id, perm) in bucket.authorized_keys() { + if perm.is_any() { + helper + .set_bucket_key_permissions(bucket.id, key_id, BucketKeyPerm::NO_PERMISSIONS) + .await?; + } + } + // 2. delete all local aliases + for ((key_id, alias), _, active) in state.local_aliases.items().iter() { + if *active { + helper + .unset_local_bucket_alias(bucket.id, key_id, alias) + .await?; + } + } + // 3. delete all global aliases + for (alias, _, active) in state.aliases.items().iter() { + if *active { + helper.purge_global_bucket_alias(bucket.id, alias).await?; + } + } + + // 4. delete bucket + bucket.state = Deletable::delete(); + garage.bucket_table.insert(&bucket).await?; + + Ok(DeleteBucketResponse) + } +} + +impl RequestHandler for UpdateBucketRequest { + type Response = UpdateBucketResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let bucket_id = parse_bucket_id(&self.id)?; + + let mut bucket = garage + .bucket_helper() + .get_existing_bucket(bucket_id) + .await?; + + let state = bucket.state.as_option_mut().unwrap(); + + if let Some(wa) = self.body.website_access { + if wa.enabled { + let (redirect_all, routing_rules) = match state.website_config.get() { + Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()), + None => (None, Vec::new()), + }; + state.website_config.update(Some(WebsiteConfig { + index_document: wa.index_document.ok_or_bad_request( + "Please specify indexDocument when enabling website access.", + )?, + error_document: wa.error_document, + redirect_all, + routing_rules, + })); + } else { + if wa.index_document.is_some() || wa.error_document.is_some() { + return Err(Error::bad_request( + "Cannot specify indexDocument or errorDocument when disabling website access.", + )); + } + state.website_config.update(None); + } + } + + if let Some(q) = self.body.quotas { + state.quotas.update(BucketQuotas { + max_size: q.max_size, + max_objects: q.max_objects, + }); + } + + garage.bucket_table.insert(&bucket).await?; + + Ok(UpdateBucketResponse( + bucket_info_results(garage, bucket.id).await?, + )) + } +} + +impl RequestHandler for CleanupIncompleteUploadsRequest { + type Response = CleanupIncompleteUploadsResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let duration = Duration::from_secs(self.older_than_secs); + + let bucket_id = parse_bucket_id(&self.bucket_id)?; + + let count = garage + .bucket_helper() + .cleanup_incomplete_uploads(&bucket_id, duration) + .await?; + + Ok(CleanupIncompleteUploadsResponse { + uploads_deleted: count as u64, + }) + } +} + +// ---- BUCKET/KEY PERMISSIONS ---- + +impl RequestHandler for AllowBucketKeyRequest { + type Response = AllowBucketKeyResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let res = handle_bucket_change_key_perm(garage, self.0, true).await?; + Ok(AllowBucketKeyResponse(res)) + } +} + +impl RequestHandler for DenyBucketKeyRequest { + type Response = DenyBucketKeyResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let res = handle_bucket_change_key_perm(garage, self.0, false).await?; + Ok(DenyBucketKeyResponse(res)) + } +} + +pub async fn handle_bucket_change_key_perm( + garage: &Arc, + req: BucketKeyPermChangeRequest, + new_perm_flag: bool, +) -> Result { + let helper = garage.locked_helper().await; + + let bucket_id = parse_bucket_id(&req.bucket_id)?; + + let bucket = helper.bucket().get_existing_bucket(bucket_id).await?; + let state = bucket.state.as_option().unwrap(); + + let key = helper.key().get_existing_key(&req.access_key_id).await?; + + let mut perm = state + .authorized_keys + .get(&key.key_id) + .cloned() + .unwrap_or(BucketKeyPerm::NO_PERMISSIONS); + + if req.permissions.read { + perm.allow_read = new_perm_flag; + } + if req.permissions.write { + perm.allow_write = new_perm_flag; + } + if req.permissions.owner { + perm.allow_owner = new_perm_flag; + } + + helper + .set_bucket_key_permissions(bucket.id, &key.key_id, perm) + .await?; + + bucket_info_results(garage, bucket.id).await +} + +// ---- BUCKET ALIASES ---- + +impl RequestHandler for AddBucketAliasRequest { + type Response = AddBucketAliasResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let bucket_id = parse_bucket_id(&self.bucket_id)?; + + let helper = garage.locked_helper().await; + + match self.alias { + BucketAliasEnum::Global { global_alias } => { + helper + .set_global_bucket_alias(bucket_id, &global_alias) + .await? + } + BucketAliasEnum::Local { + local_alias, + access_key_id, + } => { + helper + .set_local_bucket_alias(bucket_id, &access_key_id, &local_alias) + .await? + } + } + + Ok(AddBucketAliasResponse( + bucket_info_results(garage, bucket_id).await?, + )) + } +} + +impl RequestHandler for RemoveBucketAliasRequest { + type Response = RemoveBucketAliasResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let bucket_id = parse_bucket_id(&self.bucket_id)?; + + let helper = garage.locked_helper().await; + + match self.alias { + BucketAliasEnum::Global { global_alias } => { + helper + .unset_global_bucket_alias(bucket_id, &global_alias) + .await? + } + BucketAliasEnum::Local { + local_alias, + access_key_id, + } => { + helper + .unset_local_bucket_alias(bucket_id, &access_key_id, &local_alias) + .await? + } + } + + Ok(RemoveBucketAliasResponse( + bucket_info_results(garage, bucket_id).await?, + )) + } +} + +// ---- HELPER ---- + async fn bucket_info_results( garage: &Arc, - bucket: Bucket, + bucket_id: Uuid, ) -> Result { + let bucket = garage + .bucket_helper() + .get_existing_bucket(bucket_id) + .await?; + let counters = garage .object_counter_table .table @@ -268,348 +609,6 @@ async fn bucket_info_results( Ok(res) } -impl RequestHandler for CreateBucketRequest { - type Response = CreateBucketResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let helper = garage.locked_helper().await; - - if let Some(ga) = &self.global_alias { - if !is_valid_bucket_name(ga) { - return Err(Error::bad_request(format!( - "{}: {}", - ga, INVALID_BUCKET_NAME_MESSAGE - ))); - } - - if let Some(alias) = garage.bucket_alias_table.get(&EmptyKey, ga).await? { - if alias.state.get().is_some() { - return Err(CommonError::BucketAlreadyExists.into()); - } - } - } - - if let Some(la) = &self.local_alias { - if !is_valid_bucket_name(&la.alias) { - return Err(Error::bad_request(format!( - "{}: {}", - la.alias, INVALID_BUCKET_NAME_MESSAGE - ))); - } - - let key = helper.key().get_existing_key(&la.access_key_id).await?; - let state = key.state.as_option().unwrap(); - if matches!(state.local_aliases.get(&la.alias), Some(_)) { - return Err(Error::bad_request("Local alias already exists")); - } - } - - let bucket = Bucket::new(); - garage.bucket_table.insert(&bucket).await?; - - if let Some(ga) = &self.global_alias { - helper.set_global_bucket_alias(bucket.id, ga).await?; - } - - if let Some(la) = &self.local_alias { - helper - .set_local_bucket_alias(bucket.id, &la.access_key_id, &la.alias) - .await?; - - if la.allow.read || la.allow.write || la.allow.owner { - helper - .set_bucket_key_permissions( - bucket.id, - &la.access_key_id, - BucketKeyPerm { - timestamp: now_msec(), - allow_read: la.allow.read, - allow_write: la.allow.write, - allow_owner: la.allow.owner, - }, - ) - .await?; - } - } - - Ok(CreateBucketResponse( - bucket_info_results(garage, bucket).await?, - )) - } -} - -impl RequestHandler for DeleteBucketRequest { - type Response = DeleteBucketResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let helper = garage.locked_helper().await; - - let bucket_id = parse_bucket_id(&self.id)?; - - let mut bucket = helper.bucket().get_existing_bucket(bucket_id).await?; - let state = bucket.state.as_option().unwrap(); - - // Check bucket is empty - if !helper.bucket().is_bucket_empty(bucket_id).await? { - return Err(CommonError::BucketNotEmpty.into()); - } - - // --- done checking, now commit --- - // 1. delete authorization from keys that had access - for (key_id, perm) in bucket.authorized_keys() { - if perm.is_any() { - helper - .set_bucket_key_permissions(bucket.id, key_id, BucketKeyPerm::NO_PERMISSIONS) - .await?; - } - } - // 2. delete all local aliases - for ((key_id, alias), _, active) in state.local_aliases.items().iter() { - if *active { - helper - .unset_local_bucket_alias(bucket.id, key_id, alias) - .await?; - } - } - // 3. delete all global aliases - for (alias, _, active) in state.aliases.items().iter() { - if *active { - helper.purge_global_bucket_alias(bucket.id, alias).await?; - } - } - - // 4. delete bucket - bucket.state = Deletable::delete(); - garage.bucket_table.insert(&bucket).await?; - - Ok(DeleteBucketResponse) - } -} - -impl RequestHandler for UpdateBucketRequest { - type Response = UpdateBucketResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let bucket_id = parse_bucket_id(&self.id)?; - - let mut bucket = garage - .bucket_helper() - .get_existing_bucket(bucket_id) - .await?; - - let state = bucket.state.as_option_mut().unwrap(); - - if let Some(wa) = self.body.website_access { - if wa.enabled { - let (redirect_all, routing_rules) = match state.website_config.get() { - Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()), - None => (None, Vec::new()), - }; - state.website_config.update(Some(WebsiteConfig { - index_document: wa.index_document.ok_or_bad_request( - "Please specify indexDocument when enabling website access.", - )?, - error_document: wa.error_document, - redirect_all, - routing_rules, - })); - } else { - if wa.index_document.is_some() || wa.error_document.is_some() { - return Err(Error::bad_request( - "Cannot specify indexDocument or errorDocument when disabling website access.", - )); - } - state.website_config.update(None); - } - } - - if let Some(q) = self.body.quotas { - state.quotas.update(BucketQuotas { - max_size: q.max_size, - max_objects: q.max_objects, - }); - } - - garage.bucket_table.insert(&bucket).await?; - - Ok(UpdateBucketResponse( - bucket_info_results(garage, bucket).await?, - )) - } -} - -impl RequestHandler for CleanupIncompleteUploadsRequest { - type Response = CleanupIncompleteUploadsResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let duration = Duration::from_secs(self.older_than_secs); - - let bucket_id = parse_bucket_id(&self.bucket_id)?; - - let count = garage - .bucket_helper() - .cleanup_incomplete_uploads(&bucket_id, duration) - .await?; - - Ok(CleanupIncompleteUploadsResponse { - uploads_deleted: count as u64, - }) - } -} - -// ---- BUCKET/KEY PERMISSIONS ---- - -impl RequestHandler for AllowBucketKeyRequest { - type Response = AllowBucketKeyResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let res = handle_bucket_change_key_perm(garage, self.0, true).await?; - Ok(AllowBucketKeyResponse(res)) - } -} - -impl RequestHandler for DenyBucketKeyRequest { - type Response = DenyBucketKeyResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let res = handle_bucket_change_key_perm(garage, self.0, false).await?; - Ok(DenyBucketKeyResponse(res)) - } -} - -pub async fn handle_bucket_change_key_perm( - garage: &Arc, - req: BucketKeyPermChangeRequest, - new_perm_flag: bool, -) -> Result { - let helper = garage.locked_helper().await; - - let bucket_id = parse_bucket_id(&req.bucket_id)?; - - let bucket = helper.bucket().get_existing_bucket(bucket_id).await?; - let state = bucket.state.as_option().unwrap(); - - let key = helper.key().get_existing_key(&req.access_key_id).await?; - - let mut perm = state - .authorized_keys - .get(&key.key_id) - .cloned() - .unwrap_or(BucketKeyPerm::NO_PERMISSIONS); - - if req.permissions.read { - perm.allow_read = new_perm_flag; - } - if req.permissions.write { - perm.allow_write = new_perm_flag; - } - if req.permissions.owner { - perm.allow_owner = new_perm_flag; - } - - helper - .set_bucket_key_permissions(bucket.id, &key.key_id, perm) - .await?; - - bucket_info_results(garage, bucket).await -} - -// ---- BUCKET ALIASES ---- - -impl RequestHandler for AddBucketAliasRequest { - type Response = AddBucketAliasResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let bucket_id = parse_bucket_id(&self.bucket_id)?; - - let helper = garage.locked_helper().await; - - let bucket = match self.alias { - BucketAliasEnum::Global { global_alias } => { - helper - .set_global_bucket_alias(bucket_id, &global_alias) - .await? - } - BucketAliasEnum::Local { - local_alias, - access_key_id, - } => { - helper - .set_local_bucket_alias(bucket_id, &access_key_id, &local_alias) - .await? - } - }; - - Ok(AddBucketAliasResponse( - bucket_info_results(garage, bucket).await?, - )) - } -} - -impl RequestHandler for RemoveBucketAliasRequest { - type Response = RemoveBucketAliasResponse; - - async fn handle( - self, - garage: &Arc, - _admin: &Admin, - ) -> Result { - let bucket_id = parse_bucket_id(&self.bucket_id)?; - - let helper = garage.locked_helper().await; - - let bucket = match self.alias { - BucketAliasEnum::Global { global_alias } => { - helper - .unset_global_bucket_alias(bucket_id, &global_alias) - .await? - } - BucketAliasEnum::Local { - local_alias, - access_key_id, - } => { - helper - .unset_local_bucket_alias(bucket_id, &access_key_id, &local_alias) - .await? - } - }; - - Ok(RemoveBucketAliasResponse( - bucket_info_results(garage, bucket).await?, - )) - } -} - -// ---- HELPER ---- - fn parse_bucket_id(id: &str) -> Result { let id_hex = hex::decode(id).ok_or_bad_request("Invalid bucket id")?; Ok(Uuid::try_from(&id_hex).ok_or_bad_request("Invalid bucket id")?) diff --git a/src/model/helper/locked.rs b/src/model/helper/locked.rs index 9d6a8d36..482e91b0 100644 --- a/src/model/helper/locked.rs +++ b/src/model/helper/locked.rs @@ -6,7 +6,6 @@ use garage_util::time::*; use garage_table::util::*; use crate::bucket_alias_table::*; -use crate::bucket_table::*; use crate::garage::Garage; use crate::helper::bucket::BucketHelper; use crate::helper::error::*; @@ -57,7 +56,7 @@ impl<'a> LockedHelper<'a> { &self, bucket_id: Uuid, alias_name: &String, - ) -> Result { + ) -> Result<(), Error> { if !is_valid_bucket_name(alias_name) { return Err(Error::InvalidBucketName(alias_name.to_string())); } @@ -101,7 +100,7 @@ impl<'a> LockedHelper<'a> { bucket_p.aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, true); self.0.bucket_table.insert(&bucket).await?; - Ok(bucket) + Ok(()) } /// Unsets an alias for a bucket in global namespace. @@ -113,7 +112,7 @@ impl<'a> LockedHelper<'a> { &self, bucket_id: Uuid, alias_name: &String, - ) -> Result { + ) -> Result<(), Error> { let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?; let bucket_state = bucket.state.as_option_mut().unwrap(); @@ -157,7 +156,7 @@ impl<'a> LockedHelper<'a> { bucket_state.aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, false); self.0.bucket_table.insert(&bucket).await?; - Ok(bucket) + Ok(()) } /// Ensures a bucket does not have a certain global alias. @@ -216,7 +215,7 @@ impl<'a> LockedHelper<'a> { bucket_id: Uuid, key_id: &String, alias_name: &String, - ) -> Result { + ) -> Result<(), Error> { let key_helper = KeyHelper(self.0); if !is_valid_bucket_name(alias_name) { @@ -258,7 +257,7 @@ impl<'a> LockedHelper<'a> { bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, true); self.0.bucket_table.insert(&bucket).await?; - Ok(bucket) + Ok(()) } /// Unsets an alias for a bucket in the local namespace of a key. @@ -272,7 +271,7 @@ impl<'a> LockedHelper<'a> { bucket_id: Uuid, key_id: &String, alias_name: &String, - ) -> Result { + ) -> Result<(), Error> { let key_helper = KeyHelper(self.0); let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?; @@ -331,7 +330,7 @@ impl<'a> LockedHelper<'a> { bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false); self.0.bucket_table.insert(&bucket).await?; - Ok(bucket) + Ok(()) } /// Sets permissions for a key on a bucket. From fd0e23e984a2a9fcc728e09111cbd2491c7ec751 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 6 Apr 2025 13:23:25 +0200 Subject: [PATCH 102/192] admin api: implement InspectObject (fix #892) --- doc/api/garage-admin-v2.json | 164 ++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 43 +++++++++ src/api/admin/bucket.rs | 123 ++++++++++++++++++++++++ src/api/admin/openapi.rs | 15 +++ src/api/admin/router_v2.rs | 5 +- src/garage/cli/remote/bucket.rs | 70 ++++++++++++++ src/garage/cli/structs.rs | 12 +++ 7 files changed, 431 insertions(+), 1 deletion(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 31aaa915..a7bea179 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1054,6 +1054,48 @@ } } }, + "/v2/InspectObject": { + "get": { + "tags": [ + "Bucket" + ], + "description": "\nReturns detailed information about an object in a bucket, including its internal state in Garage.\n ", + "operationId": "InspectObject", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns exhaustive information about the object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InspectObjectResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/LaunchRepairOperation": { "post": { "tags": [ @@ -2571,6 +2613,128 @@ "ImportKeyResponse": { "$ref": "#/components/schemas/GetKeyInfoResponse" }, + "InspectObjectBlock": { + "type": "object", + "required": [ + "partNumber", + "offset", + "hash", + "size" + ], + "properties": { + "hash": { + "type": "string" + }, + "offset": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "partNumber": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "InspectObjectResponse": { + "type": "object", + "required": [ + "bucketId", + "key", + "versions" + ], + "properties": { + "bucketId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectObjectVersion" + } + } + } + }, + "InspectObjectVersion": { + "type": "object", + "required": [ + "uuid", + "timestamp", + "encrypted", + "uploading", + "aborted", + "deleteMarker", + "inline" + ], + "properties": { + "aborted": { + "type": "boolean" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectObjectBlock" + } + }, + "deleteMarker": { + "type": "boolean" + }, + "encrypted": { + "type": "boolean" + }, + "etag": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": false, + "prefixItems": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + }, + "inline": { + "type": "boolean" + }, + "size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "uploading": { + "type": "boolean" + }, + "uuid": { + "type": "string" + } + } + }, "KeyInfoBucketResponse": { "type": "object", "required": [ diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index b865ac88..97f4583b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -80,6 +80,7 @@ admin_endpoints![ UpdateBucket, DeleteBucket, CleanupIncompleteUploads, + InspectObject, // Operations on permissions for keys on buckets AllowBucketKey, @@ -907,6 +908,48 @@ pub struct CleanupIncompleteUploadsResponse { pub uploads_deleted: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectRequest { + pub bucket_id: String, + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectResponse { + pub bucket_id: String, + pub key: String, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectVersion { + pub uuid: String, + pub timestamp: chrono::DateTime, + pub encrypted: bool, + pub uploading: bool, + pub aborted: bool, + pub delete_marker: bool, + pub inline: bool, + pub size: Option, + pub etag: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub headers: Vec<(String, String)>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectBlock { + pub part_number: u64, + pub offset: u64, + pub hash: String, + pub size: u64, +} + // ********************************************** // Operations on permissions for keys on buckets // ********************************************** diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ce12b4cf..d825dfb4 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use chrono::DateTime; + use garage_util::crdt::*; use garage_util::data::*; use garage_util::time::*; @@ -349,6 +351,127 @@ impl RequestHandler for CleanupIncompleteUploadsRequest { } } +impl RequestHandler for InspectObjectRequest { + type Response = InspectObjectResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let bucket_id = parse_bucket_id(&self.bucket_id)?; + + let object = garage + .object_table + .get(&bucket_id, &self.key) + .await? + .ok_or_else(|| Error::bad_request("object not found"))?; + + let mut versions = vec![]; + for obj_ver in object.versions().iter() { + let ver = garage.version_table.get(&obj_ver.uuid, &EmptyKey).await?; + let blocks = ver + .map(|v| { + v.blocks + .items() + .iter() + .map(|(vk, vb)| InspectObjectBlock { + part_number: vk.part_number, + offset: vk.offset, + hash: hex::encode(&vb.hash), + size: vb.size, + }) + .collect::>() + }) + .unwrap_or_default(); + let uuid = hex::encode(&obj_ver.uuid); + let timestamp = DateTime::from_timestamp_millis(obj_ver.timestamp as i64) + .expect("invalid timestamp in db"); + match &obj_ver.state { + ObjectVersionState::Uploading { encryption, .. } => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + encrypted: !matches!(encryption, ObjectVersionEncryption::Plaintext { .. }), + uploading: true, + headers: match encryption { + ObjectVersionEncryption::Plaintext { inner } => inner.headers.clone(), + _ => vec![], + }, + blocks, + ..Default::default() + }); + } + ObjectVersionState::Complete(data) => match data { + ObjectVersionData::DeleteMarker => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + delete_marker: true, + ..Default::default() + }); + } + ObjectVersionData::Inline(meta, _) => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + inline: true, + size: Some(meta.size), + etag: Some(meta.etag.clone()), + encrypted: !matches!( + meta.encryption, + ObjectVersionEncryption::Plaintext { .. } + ), + headers: match &meta.encryption { + ObjectVersionEncryption::Plaintext { inner } => { + inner.headers.clone() + } + _ => vec![], + }, + ..Default::default() + }); + } + ObjectVersionData::FirstBlock(meta, _) => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + size: Some(meta.size), + etag: Some(meta.etag.clone()), + encrypted: !matches!( + meta.encryption, + ObjectVersionEncryption::Plaintext { .. } + ), + headers: match &meta.encryption { + ObjectVersionEncryption::Plaintext { inner } => { + inner.headers.clone() + } + _ => vec![], + }, + blocks, + ..Default::default() + }); + } + }, + ObjectVersionState::Aborted => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + aborted: true, + blocks, + ..Default::default() + }); + } + } + } + + Ok(InspectObjectResponse { + bucket_id: hex::encode(&object.bucket_id), + key: object.key, + versions, + }) + } +} + // ---- BUCKET/KEY PERMISSIONS ---- impl RequestHandler for AllowBucketKeyRequest { diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index b7ffdcf1..6e9cb5e1 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -509,6 +509,20 @@ fn DeleteBucket() -> () {} )] fn CleanupIncompleteUploads() -> () {} +#[utoipa::path(get, + path = "/v2/InspectObject", + tag = "Bucket", + description = " +Returns detailed information about an object in a bucket, including its internal state in Garage. + ", + params(InspectObjectRequest), + responses( + (status = 200, description = "Returns exhaustive information about the object", body = InspectObjectResponse), + (status = 500, description = "Internal server error") + ), +)] +fn InspectObject() -> () {} + // ********************************************** // Operations on permissions for keys on buckets // ********************************************** @@ -872,6 +886,7 @@ impl Modify for SecurityAddon { UpdateBucket, DeleteBucket, CleanupIncompleteUploads, + InspectObject, // Operations on permissions AllowBucketKey, DenyBucketKey, diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 73f98308..3051dae4 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -62,6 +62,7 @@ impl AdminApiRequest { POST DeleteBucket (query::id), POST UpdateBucket (body_field, query::id), POST CleanupIncompleteUploads (body), + GET InspectObject (query::bucket_id, query::key), // Bucket-key permissions POST AllowBucketKey (body), POST DenyBucketKey (body), @@ -267,6 +268,8 @@ generateQueryParameters! { "globalAlias" => global_alias, "alias" => alias, "accessKeyId" => access_key_id, - "showSecretKey" => show_secret_key + "showSecretKey" => show_secret_key, + "bucketId" => bucket_id, + "key" => key ] } diff --git a/src/garage/cli/remote/bucket.rs b/src/garage/cli/remote/bucket.rs index 09e3de64..bc018b33 100644 --- a/src/garage/cli/remote/bucket.rs +++ b/src/garage/cli/remote/bucket.rs @@ -24,6 +24,7 @@ impl Cli { BucketOperation::CleanupIncompleteUploads(query) => { self.cmd_cleanup_incomplete_uploads(query).await } + BucketOperation::InspectObject(query) => self.cmd_inspect_object(query).await, } } @@ -407,6 +408,75 @@ impl Cli { Ok(()) } + + pub async fn cmd_inspect_object(&self, opt: InspectObjectOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.bucket), + }) + .await?; + + let info = self + .api_request(InspectObjectRequest { + bucket_id: bucket.id, + key: opt.key, + }) + .await?; + + for ver in info.versions { + println!("==== OBJECT VERSION ===="); + let mut tab = vec![ + format!("Bucket ID:\t{}", info.bucket_id), + format!("Key:\t{}", info.key), + format!("Version ID:\t{}", ver.uuid), + format!("Timestamp:\t{}", ver.timestamp), + ]; + if let Some(size) = ver.size { + let bs = bytesize::ByteSize::b(size); + tab.push(format!( + "Size:\t{} ({})", + bs.to_string_as(true), + bs.to_string_as(false) + )); + tab.push(format!("Size (exact):\t{}", size)); + if !ver.blocks.is_empty() { + tab.push(format!("Number of blocks:\t{:?}", ver.blocks.len())); + } + } + if let Some(etag) = ver.etag { + tab.push(format!("Etag:\t{}", etag)); + } + tab.extend([ + format!("Encrypted:\t{}", ver.encrypted), + format!("Uploading:\t{}", ver.uploading), + format!("Aborted:\t{}", ver.aborted), + format!("Delete marker:\t{}", ver.delete_marker), + format!("Inline data:\t{}", ver.inline), + ]); + if !ver.headers.is_empty() { + tab.push(String::new()); + tab.extend(ver.headers.iter().map(|(k, v)| format!("{}\t{}", k, v))); + } + format_table(tab); + + if !ver.blocks.is_empty() { + let mut tab = vec!["Part#\tOffset\tBlock hash\tSize".to_string()]; + tab.extend(ver.blocks.iter().map(|b| { + format!( + "{:4}\t{:9}\t{}\t{:9}", + b.part_number, b.offset, b.hash, b.size + ) + })); + println!(); + format_table(tab); + } + println!(); + } + + Ok(()) + } } fn print_bucket_info(bucket: &GetBucketInfoResponse) { diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 9a6d912c..20079709 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -265,6 +265,10 @@ pub enum BucketOperation { /// Clean up (abort) old incomplete multipart uploads #[structopt(name = "cleanup-incomplete-uploads", version = garage_version())] CleanupIncompleteUploads(CleanupIncompleteUploadsOpt), + + /// Inspect an object in a bucket + #[structopt(name = "inspect-object", version = garage_version())] + InspectObject(InspectObjectOpt), } #[derive(StructOpt, Debug)] @@ -377,6 +381,14 @@ pub struct CleanupIncompleteUploadsOpt { pub buckets: Vec, } +#[derive(StructOpt, Debug)] +pub struct InspectObjectOpt { + /// Name or ID of bucket + pub bucket: String, + /// Key of object to inspect + pub key: String, +} + // ------------------------ // ---- garage key ... ---- // ------------------------ From 5e7307cbf36215e4071978dbf6815b97acd3c8bc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 6 Apr 2025 14:19:48 +0200 Subject: [PATCH 103/192] admin api: add comments for InspectObject --- doc/api/garage-admin-v2.json | 51 +++++++++++++++++++++++++----------- src/api/admin/api.rs | 19 ++++++++++++++ src/api/admin/bucket.rs | 2 +- src/api/admin/error.rs | 8 +++++- src/api/admin/openapi.rs | 8 ++++++ 5 files changed, 71 insertions(+), 17 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index a7bea179..7819a0a6 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1059,7 +1059,7 @@ "tags": [ "Bucket" ], - "description": "\nReturns detailed information about an object in a bucket, including its internal state in Garage.\n ", + "description": "\nReturns detailed information about an object in a bucket, including its internal state in Garage.\n\nThis API call can be used to list the data blocks referenced by an object,\nas well as to view metadata associated to the object.\n\nThis call may return a list of more than one version for the object, for instance in the\ncase where there is a currently stored version of the object, and a newer version whose\nupload is in progress and not yet finished.\n ", "operationId": "InspectObject", "parameters": [ { @@ -1090,6 +1090,9 @@ } } }, + "404": { + "description": "Object not found" + }, "500": { "description": "Internal server error" } @@ -2623,21 +2626,25 @@ ], "properties": { "hash": { - "type": "string" + "type": "string", + "description": "Hash (blake2 sum) of the block's data" }, "offset": { "type": "integer", "format": "int64", + "description": "Offset of this block within the part", "minimum": 0 }, "partNumber": { "type": "integer", "format": "int64", + "description": "Part number of the part containing this block, for multipart uploads", "minimum": 0 }, "size": { "type": "integer", "format": "int64", + "description": "Length of the blocks's data", "minimum": 0 } } @@ -2651,16 +2658,19 @@ ], "properties": { "bucketId": { - "type": "string" + "type": "string", + "description": "ID of the bucket containing the inspected object" }, "key": { - "type": "string" + "type": "string", + "description": "Key of the inspected object" }, "versions": { "type": "array", "items": { "$ref": "#/components/schemas/InspectObjectVersion" - } + }, + "description": "List of versions currently stored for this object" } } }, @@ -2677,25 +2687,30 @@ ], "properties": { "aborted": { - "type": "boolean" + "type": "boolean", + "description": "Whether this is an aborted upload" }, "blocks": { "type": "array", "items": { "$ref": "#/components/schemas/InspectObjectBlock" - } + }, + "description": "List of data blocks for this object version" }, "deleteMarker": { - "type": "boolean" + "type": "boolean", + "description": "Whether this version is a delete marker (a tombstone indicating that a previous version of\nthe object has been deleted)" }, "encrypted": { - "type": "boolean" + "type": "boolean", + "description": "Whether this object version was created with SSE-C encryption" }, "etag": { "type": [ "string", "null" - ] + ], + "description": "Etag of this object version" }, "headers": { "type": "array", @@ -2710,10 +2725,12 @@ "type": "string" } ] - } + }, + "description": "Metadata (HTTP headers) associated with this object version" }, "inline": { - "type": "boolean" + "type": "boolean", + "description": "Whether the object's data is stored inline (for small objects)" }, "size": { "type": [ @@ -2721,17 +2738,21 @@ "null" ], "format": "int64", + "description": "Size of the object, in bytes", "minimum": 0 }, "timestamp": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Creation timestamp of this object version" }, "uploading": { - "type": "boolean" + "type": "boolean", + "description": "Whether this object version is still uploading" }, "uuid": { - "type": "string" + "type": "string", + "description": "Version ID" } } }, diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 97f4583b..ffb9456b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -918,25 +918,40 @@ pub struct InspectObjectRequest { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InspectObjectResponse { + /// ID of the bucket containing the inspected object pub bucket_id: String, + /// Key of the inspected object pub key: String, + /// List of versions currently stored for this object pub versions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] #[serde(rename_all = "camelCase")] pub struct InspectObjectVersion { + /// Version ID pub uuid: String, + /// Creation timestamp of this object version pub timestamp: chrono::DateTime, + /// Whether this object version was created with SSE-C encryption pub encrypted: bool, + /// Whether this object version is still uploading pub uploading: bool, + /// Whether this is an aborted upload pub aborted: bool, + /// Whether this version is a delete marker (a tombstone indicating that a previous version of + /// the object has been deleted) pub delete_marker: bool, + /// Whether the object's data is stored inline (for small objects) pub inline: bool, + /// Size of the object, in bytes pub size: Option, + /// Etag of this object version pub etag: Option, + /// Metadata (HTTP headers) associated with this object version #[serde(default, skip_serializing_if = "Vec::is_empty")] pub headers: Vec<(String, String)>, + /// List of data blocks for this object version #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, } @@ -944,9 +959,13 @@ pub struct InspectObjectVersion { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InspectObjectBlock { + /// Part number of the part containing this block, for multipart uploads pub part_number: u64, + /// Offset of this block within the part pub offset: u64, + /// Hash (blake2 sum) of the block's data pub hash: String, + /// Length of the blocks's data pub size: u64, } diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index d825dfb4..af26200b 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -365,7 +365,7 @@ impl RequestHandler for InspectObjectRequest { .object_table .get(&bucket_id, &self.key) .await? - .ok_or_else(|| Error::bad_request("object not found"))?; + .ok_or_else(|| Error::NoSuchKey)?; let mut versions = vec![]; for obj_ver in object.versions().iter() { diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index f12a936e..8fbbc895 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -37,6 +37,10 @@ pub enum Error { #[error(display = "Worker not found: {}", _0)] NoSuchWorker(u64), + /// The object requested don't exists + #[error(display = "Key not found")] + NoSuchKey, + /// In Import key, the key already exists #[error( display = "Key {} already exists in data store. Even if it is deleted, we can't let you create a new key with the same ID. Sorry.", @@ -69,6 +73,7 @@ impl Error { Error::NoSuchWorker(_) => "NoSuchWorker", Error::NoSuchBlock(_) => "NoSuchBlock", Error::KeyAlreadyExists(_) => "KeyAlreadyExists", + Error::NoSuchKey => "NoSuchKey", } } } @@ -81,7 +86,8 @@ impl ApiError for Error { Error::NoSuchAdminToken(_) | Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) - | Error::NoSuchBlock(_) => StatusCode::NOT_FOUND, + | Error::NoSuchBlock(_) + | Error::NoSuchKey => StatusCode::NOT_FOUND, Error::KeyAlreadyExists(_) => StatusCode::CONFLICT, } } diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 6e9cb5e1..f1b90676 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -514,10 +514,18 @@ fn CleanupIncompleteUploads() -> () {} tag = "Bucket", description = " Returns detailed information about an object in a bucket, including its internal state in Garage. + +This API call can be used to list the data blocks referenced by an object, +as well as to view metadata associated to the object. + +This call may return a list of more than one version for the object, for instance in the +case where there is a currently stored version of the object, and a newer version whose +upload is in progress and not yet finished. ", params(InspectObjectRequest), responses( (status = 200, description = "Returns exhaustive information about the object", body = InspectObjectResponse), + (status = 404, description = "Object not found"), (status = 500, description = "Internal server error") ), )] From 9ec3f8cc3c09329761f711e35475b6272b6257ed Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sat, 12 Apr 2025 23:18:50 +0200 Subject: [PATCH 104/192] metadata: Create compact LMDB snapshots See #1006 LMDB files never shrink, so we can end up with a large database that contains a smaller amount of actual data. Compacting the snapshots is an easy win: it will write faster to disk, take less space, and if needed you can reimport an already-compacted snapshot as the main database. --- src/db/lmdb_adapter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/lmdb_adapter.rs b/src/db/lmdb_adapter.rs index 40f1c867..259aa566 100644 --- a/src/db/lmdb_adapter.rs +++ b/src/db/lmdb_adapter.rs @@ -109,7 +109,7 @@ impl IDb for LmdbDb { let mut path = to.clone(); path.push("data.mdb"); self.db - .copy_to_path(path, heed::CompactionOption::Disabled)?; + .copy_to_path(path, heed::CompactionOption::Enabled)?; Ok(()) } From 2f21181ccb26564ecdbb6425e568f1bd5cfb47df Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 10:29:23 +0200 Subject: [PATCH 105/192] publish bucket creation date in admin api and CLI --- doc/api/garage-admin-v2.json | 11 +++++++++++ src/api/admin/api.rs | 12 ++++++++---- src/api/admin/bucket.rs | 4 ++++ src/garage/cli/remote/bucket.rs | 12 +++++++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 7819a0a6..364d170b 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2280,6 +2280,7 @@ "type": "object", "required": [ "id", + "created", "globalAliases", "websiteAccess", "keys", @@ -2297,6 +2298,11 @@ "format": "int64", "description": "Total number of bytes used by objects in this bucket" }, + "created": { + "type": "string", + "format": "date-time", + "description": "Bucket creation date" + }, "globalAliases": { "type": "array", "items": { @@ -2873,10 +2879,15 @@ "type": "object", "required": [ "id", + "created", "globalAliases", "localAliases" ], "properties": { + "created": { + "type": "string", + "format": "date-time" + }, "globalAliases": { "type": "array", "items": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index ffb9456b..d2daa988 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -3,6 +3,7 @@ use std::convert::TryFrom; use std::net::SocketAddr; use std::sync::Arc; +use chrono::{DateTime, Utc}; use paste::paste; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -321,11 +322,11 @@ pub struct GetAdminTokenInfoResponse { /// Identifier of the admin token (which is also a prefix of the full bearer token) pub id: Option, /// Creation date - pub created: Option>, + pub created: Option>, /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 - pub expiration: Option>, + pub expiration: Option>, /// Whether this admin token is expired already pub expired: bool, /// Scope of the admin API token, a list of admin endpoint names (such as @@ -364,7 +365,7 @@ pub struct UpdateAdminTokenRequestBody { /// Name of the admin API token pub name: Option, /// Expiration time and date, formatted according to RFC 3339 - pub expiration: Option>, + pub expiration: Option>, /// Scope of the admin API token, a list of admin endpoint names (such as /// `GetClusterStatus`, etc), or the special value `*` to allow all /// admin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or @@ -759,6 +760,7 @@ pub struct ListBucketsResponse(pub Vec); #[serde(rename_all = "camelCase")] pub struct ListBucketsResponseItem { pub id: String, + pub created: DateTime, pub global_aliases: Vec, pub local_aliases: Vec, } @@ -788,6 +790,8 @@ pub struct GetBucketInfoRequest { pub struct GetBucketInfoResponse { /// Identifier of the bucket pub id: String, + /// Bucket creation date + pub created: DateTime, /// List of global aliases for this bucket pub global_aliases: Vec, /// Whether website acces is enabled for this bucket @@ -932,7 +936,7 @@ pub struct InspectObjectVersion { /// Version ID pub uuid: String, /// Creation timestamp of this object version - pub timestamp: chrono::DateTime, + pub timestamp: DateTime, /// Whether this object version was created with SSE-C encryption pub encrypted: bool, /// Whether this object version is still uploading diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index af26200b..b0fd101b 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -48,6 +48,8 @@ impl RequestHandler for ListBucketsRequest { let state = b.state.as_option().unwrap(); ListBucketsResponseItem { id: hex::encode(b.id), + created: DateTime::from_timestamp_millis(state.creation_date as i64) + .expect("invalid timestamp stored in db"), global_aliases: state .aliases .items() @@ -677,6 +679,8 @@ async fn bucket_info_results( let quotas = state.quotas.get(); let res = GetBucketInfoResponse { id: hex::encode(bucket.id), + created: DateTime::from_timestamp_millis(state.creation_date as i64) + .expect("invalid timestamp stored in db"), global_aliases: state .aliases .items() diff --git a/src/garage/cli/remote/bucket.rs b/src/garage/cli/remote/bucket.rs index bc018b33..1c0774a3 100644 --- a/src/garage/cli/remote/bucket.rs +++ b/src/garage/cli/remote/bucket.rs @@ -1,6 +1,8 @@ //use bytesize::ByteSize; use format_table::format_table; +use chrono::Local; + use garage_util::error::*; use garage_api_admin::api::*; @@ -29,13 +31,16 @@ impl Cli { } pub async fn cmd_list_buckets(&self) -> Result<(), Error> { - let buckets = self.api_request(ListBucketsRequest).await?; + let mut buckets = self.api_request(ListBucketsRequest).await?; - let mut table = vec!["ID\tGlobal aliases\tLocal aliases".to_string()]; + buckets.0.sort_by_key(|x| x.created); + + let mut table = vec!["ID\tCreated\tGlobal aliases\tLocal aliases".to_string()]; for bucket in buckets.0.iter() { table.push(format!( - "{:.16}\t{}\t{}", + "{:.16}\t{}\t{}\t{}", bucket.id, + bucket.created.with_timezone(&Local).date_naive(), table_list_abbr(&bucket.global_aliases), table_list_abbr( bucket @@ -484,6 +489,7 @@ fn print_bucket_info(bucket: &GetBucketInfoResponse) { let mut info = vec![ format!("Bucket:\t{}", bucket.id), + format!("Created:\t{}", bucket.created.with_timezone(&Local)), String::new(), { let size = bytesize::ByteSize::b(bucket.bytes as u64); From c56b7e20c3fbdd5427777e0e7b3c82ddb4af20d2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:09:21 +0200 Subject: [PATCH 106/192] add creation date and expiration date to access keys --- doc/api/garage-admin-v2.json | 40 +++++++++++++- src/api/admin/admin_token.rs | 6 +- src/api/admin/api.rs | 8 ++- src/api/admin/api_server.rs | 15 +---- src/api/admin/key.rs | 31 ++++++++++- src/api/common/signature/payload.rs | 8 +++ src/garage/cli/remote/admin_token.rs | 5 +- src/garage/cli/remote/key.rs | 48 ++++++++++++++-- src/model/admin_token_table.rs | 15 ++++- src/model/key_table.rs | 82 +++++++++++++++++++++++++++- 10 files changed, 225 insertions(+), 33 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 364d170b..4cdcf708 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2569,8 +2569,9 @@ "GetKeyInfoResponse": { "type": "object", "required": [ - "name", "accessKeyId", + "name", + "expired", "permissions", "buckets" ], @@ -2584,6 +2585,23 @@ "$ref": "#/components/schemas/KeyInfoBucketResponse" } }, + "created": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expired": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -2915,9 +2933,27 @@ "type": "object", "required": [ "id", - "name" + "name", + "expired" ], "properties": { + "created": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expired": { + "type": "boolean" + }, "id": { "type": "string" }, diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index 04bfdd96..b010dcf9 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -190,11 +190,7 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf expiration: params.expiration.get().map(|x| { DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") }), - expired: params - .expiration - .get() - .map(|exp| now > exp) - .unwrap_or(false), + expired: params.is_expired(now), scope: params.scope.get().0.clone(), } } diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index d2daa988..4c0cfa45 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -637,6 +637,9 @@ pub struct ListKeysResponse(pub Vec); pub struct ListKeysResponseItem { pub id: String, pub name: String, + pub created: Option>, + pub expiration: Option>, + pub expired: bool, } // ---- GetKeyInfo ---- @@ -656,8 +659,11 @@ pub struct GetKeyInfoRequest { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetKeyInfoResponse { - pub name: String, pub access_key_id: String, + pub created: Option>, + pub name: String, + pub expiration: Option>, + pub expired: bool, #[serde(default, skip_serializing_if = "is_default")] pub secret_access_key: Option, pub permissions: KeyPerm, diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 97b1fe0d..14029423 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -272,19 +272,8 @@ fn verify_authorization( .admin_token_table .get_local(&EmptyKey, &prefix.to_string())? .and_then(|k| k.state.into_option()) - .filter(|p| { - p.expiration - .get() - .map(|exp| now_msec() < exp) - .unwrap_or(true) - }) - .filter(|p| { - p.scope - .get() - .0 - .iter() - .any(|x| x == "*" || x == endpoint_name) - }) + .filter(|p| !p.is_expired(now_msec())) + .filter(|p| p.has_scope(endpoint_name)) .ok_or_else(|| Error::forbidden(invalid_msg))? .token_hash } else { diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index d1a49ab3..ee3a4d1c 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; +use chrono::DateTime; + use garage_table::*; +use garage_util::time::now_msec; use garage_model::garage::Garage; use garage_model::key_table::*; @@ -14,6 +17,8 @@ impl RequestHandler for ListKeysRequest { type Response = ListKeysResponse; async fn handle(self, garage: &Arc, _admin: &Admin) -> Result { + let now = now_msec(); + let res = garage .key_table .get_range( @@ -25,9 +30,22 @@ impl RequestHandler for ListKeysRequest { ) .await? .iter() - .map(|k| ListKeysResponseItem { - id: k.key_id.to_string(), - name: k.params().unwrap().name.get().clone(), + .map(|k| { + let p = k.params().unwrap(); + + ListKeysResponseItem { + id: k.key_id.to_string(), + name: p.name.get().clone(), + created: p.created.map(|x| { + DateTime::from_timestamp_millis(x as i64) + .expect("invalid timestamp stored in db") + }), + expiration: p.expiration.get().map(|x| { + DateTime::from_timestamp_millis(x as i64) + .expect("invalid timestamp stored in db") + }), + expired: p.is_expired(now), + } }) .collect::>(); @@ -205,6 +223,13 @@ async fn key_info_results( let res = GetKeyInfoResponse { name: key_state.name.get().clone(), + created: key_state.created.map(|x| { + DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") + }), + expiration: key_state.expiration.get().map(|x| { + DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") + }), + expired: key_state.is_expired(now_msec()), access_key_id: key.key_id.clone(), secret_access_key: if show_secret { Some(key_state.secret_key.clone()) diff --git a/src/api/common/signature/payload.rs b/src/api/common/signature/payload.rs index 8386607d..88269ba0 100644 --- a/src/api/common/signature/payload.rs +++ b/src/api/common/signature/payload.rs @@ -9,6 +9,7 @@ use sha2::{Digest, Sha256}; use garage_table::*; use garage_util::data::Hash; +use garage_util::time::now_msec; use garage_model::garage::Garage; use garage_model::key_table::*; @@ -396,6 +397,13 @@ pub fn verify_v4( .ok_or_else(|| Error::forbidden(format!("No such key: {}", &auth.key_id)))?; let key_p = key.params().unwrap(); + if key_p.is_expired(now_msec()) { + return Err(Error::forbidden(format!( + "Access key {} has expired", + key.key_id + ))); + } + let mut hmac = signing_hmac( &auth.date, &key_p.secret_key, diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 09699ad7..cd7ff9b0 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -231,9 +231,10 @@ impl Cli { } fn print_token_info(token: &GetAdminTokenInfoResponse) { + println!("==== ADMINISTRATION TOKEN INFORMATION ===="); let mut table = vec![ - format!("ID:\t{}", token.id.as_ref().unwrap()), - format!("Name:\t{}", token.name), + format!("Token ID:\t{}", token.id.as_ref().unwrap()), + format!("Token name:\t{}", token.name), format!("Created:\t{}", token.created.unwrap().with_timezone(&Local)), format!( "Validity:\t{}", diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 2c6981b6..25937efa 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -1,5 +1,7 @@ use format_table::format_table; +use chrono::Local; + use garage_util::error::*; use garage_api_admin::api::*; @@ -22,11 +24,28 @@ impl Cli { } pub async fn cmd_list_keys(&self) -> Result<(), Error> { - let keys = self.api_request(ListKeysRequest).await?; + let mut keys = self.api_request(ListKeysRequest).await?; - let mut table = vec!["ID\tName".to_string()]; + keys.0.sort_by_key(|x| x.created); + + let mut table = vec!["ID\tCreated\tName\tExpiration".to_string()]; for key in keys.0.iter() { - table.push(format!("{}\t{}", key.id, key.name)); + let exp = if key.expired { + "expired".to_string() + } else { + key.expiration + .map(|x| x.with_timezone(&Local).to_string()) + .unwrap_or("never".into()) + }; + table.push(format!( + "{}\t{}\t{}\t{}", + key.id, + key.created + .map(|x| x.with_timezone(&Local).date_naive().to_string()) + .unwrap_or_default(), + key.name, + exp + )); } format_table(table); @@ -186,15 +205,34 @@ impl Cli { fn print_key_info(key: &GetKeyInfoResponse) { println!("==== ACCESS KEY INFORMATION ===="); - format_table(vec![ - format!("Key name:\t{}", key.name), + let mut table = vec![ format!("Key ID:\t{}", key.access_key_id), + format!("Key name:\t{}", key.name), format!( "Secret key:\t{}", key.secret_access_key.as_deref().unwrap_or("(redacted)") ), + ]; + + if let Some(c) = key.created { + table.push(format!("Created:\t{}", c.with_timezone(&Local))); + } + + table.extend([ + format!( + "Validity:\t{}", + key.expired.then_some("EXPIRED").unwrap_or("valid") + ), + format!( + "Expiration:\t{}", + key.expiration + .map(|x| x.with_timezone(&Local).to_string()) + .unwrap_or("never".into()) + ), + String::new(), format!("Can create buckets:\t{}", key.permissions.create_bucket), ]); + format_table(table); println!(""); println!("==== BUCKETS FOR THIS KEY ===="); diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index ef91eb4a..0af8ec78 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -113,7 +113,7 @@ impl AdminApiToken { } } - /// Returns true if this represents a deleted bucket + /// Returns true if this represents a deleted admin token pub fn is_deleted(&self) -> bool { self.state.is_deleted() } @@ -137,6 +137,19 @@ impl AdminApiToken { } } +impl AdminApiTokenParams { + pub fn is_expired(&self, ts_now: u64) -> bool { + match *self.expiration.get() { + None => false, + Some(exp) => ts_now >= exp, + } + } + + pub fn has_scope(&self, endpoint: &str) -> bool { + self.scope.get().0.iter().any(|x| x == "*" || x == endpoint) + } +} + impl Entry for AdminApiToken { fn partition_key(&self) -> &EmptyKey { &EmptyKey diff --git a/src/model/key_table.rs b/src/model/key_table.rs index efb95f08..6cf0800b 100644 --- a/src/model/key_table.rs +++ b/src/model/key_table.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use garage_util::crdt::{self, Crdt}; use garage_util::data::*; +use garage_util::time::now_msec; use garage_table::{DeletedFilter, EmptyKey, Entry, TableSchema}; @@ -48,13 +49,82 @@ mod v08 { impl garage_util::migrate::InitialFormat for Key {} } -pub use v08::*; +mod v2 { + use crate::permission::BucketKeyPerm; + use garage_util::crdt; + use garage_util::data::Uuid; + use serde::{Deserialize, Serialize}; + + use super::v08; + + /// An api key + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Key { + /// The id of the key (immutable), used as partition key + pub key_id: String, + + /// Internal state of the key + pub state: crdt::Deletable, + } + + /// Configuration for a key + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct KeyParams { + /// Key's creation date, if known (older versions of Garage didn't keep track + /// of this information) + pub created: Option, + /// The secret_key associated (immutable) + pub secret_key: String, + + /// Name for the key + pub name: crdt::Lww, + /// The optional time of expiration of the key + pub expiration: crdt::Lww>, + + /// Flag to allow users having this key to create buckets + pub allow_create_bucket: crdt::Lww, + + /// If the key is present: it gives some permissions, + /// a map of bucket IDs (uuids) to permissions. + /// Otherwise no permissions are granted to key + pub authorized_buckets: crdt::Map, + + /// A key can have a local view of buckets names it is + /// the only one to see, this is the namespace for these aliases + pub local_aliases: crdt::LwwMap>, + } + + impl garage_util::migrate::Migrate for Key { + const VERSION_MARKER: &'static [u8] = b"G2key"; + + type Previous = v08::Key; + + fn migrate(old: v08::Key) -> Key { + Key { + key_id: old.key_id, + state: old.state.map(|x| KeyParams { + created: None, + secret_key: x.secret_key, + name: x.name, + expiration: crdt::Lww::raw(0, None), + allow_create_bucket: x.allow_create_bucket, + authorized_buckets: x.authorized_buckets, + local_aliases: x.local_aliases, + }), + } + } + } +} + +pub use v2::*; impl KeyParams { fn new(secret_key: &str, name: &str) -> Self { KeyParams { + created: Some(now_msec()), secret_key: secret_key.to_string(), name: crdt::Lww::new(name.to_string()), + expiration: crdt::Lww::new(None), allow_create_bucket: crdt::Lww::new(false), authorized_buckets: crdt::Map::new(), local_aliases: crdt::LwwMap::new(), @@ -65,6 +135,7 @@ impl KeyParams { impl Crdt for KeyParams { fn merge(&mut self, o: &Self) { self.name.merge(&o.name); + self.expiration.merge(&o.expiration); self.allow_create_bucket.merge(&o.allow_create_bucket); self.authorized_buckets.merge(&o.authorized_buckets); self.local_aliases.merge(&o.local_aliases); @@ -145,6 +216,15 @@ impl Key { } } +impl KeyParams { + pub fn is_expired(&self, ts_now: u64) -> bool { + match *self.expiration.get() { + None => false, + Some(exp) => ts_now >= exp, + } + } +} + impl Entry for Key { fn partition_key(&self) -> &EmptyKey { &EmptyKey From 590c9bb4db16c77bf3b558e58fbe03c58f87f938 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:30:58 +0200 Subject: [PATCH 107/192] possibility to update access key expiration date --- doc/api/garage-admin-v2.json | 27 +++++++++++--------- src/api/admin/api.rs | 9 ++++--- src/api/admin/key.rs | 44 ++++++++++++++++++++------------ src/garage/cli/remote/key.rs | 49 +++++++++++++++++++++++++++++++++--- src/garage/cli/structs.rs | 19 ++++++++++++++ 5 files changed, 114 insertions(+), 34 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 4cdcf708..4cc907d1 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2162,15 +2162,7 @@ "$ref": "#/components/schemas/GetBucketInfoResponse" }, "CreateKeyRequest": { - "type": "object", - "properties": { - "name": { - "type": [ - "string", - "null" - ] - } - } + "$ref": "#/components/schemas/UpdateKeyRequestBody" }, "CreateKeyResponse": { "$ref": "#/components/schemas/GetKeyInfoResponse" @@ -4115,7 +4107,8 @@ "type": "null" }, { - "$ref": "#/components/schemas/KeyPerm" + "$ref": "#/components/schemas/KeyPerm", + "description": "Permissions to allow for the key" } ] }, @@ -4125,15 +4118,25 @@ "type": "null" }, { - "$ref": "#/components/schemas/KeyPerm" + "$ref": "#/components/schemas/KeyPerm", + "description": "Permissions to deny for the key" } ] }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Expiration time and date, formatted according to RFC 3339" + }, "name": { "type": [ "string", "null" - ] + ], + "description": "Name of the API key" } } }, diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 4c0cfa45..fa6c6b2d 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -701,9 +701,7 @@ pub struct ApiBucketKeyPerm { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct CreateKeyRequest { - pub name: Option, -} +pub struct CreateKeyRequest(pub UpdateKeyRequestBody); #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateKeyResponse(pub GetKeyInfoResponse); @@ -735,8 +733,13 @@ pub struct UpdateKeyResponse(pub GetKeyInfoResponse); #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateKeyRequestBody { + /// Name of the API key pub name: Option, + /// Expiration time and date, formatted according to RFC 3339 + pub expiration: Option>, + /// Permissions to allow for the key pub allow: Option, + /// Permissions to deny for the key pub deny: Option, } diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index ee3a4d1c..07373e76 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -103,7 +103,10 @@ impl RequestHandler for CreateKeyRequest { garage: &Arc, _admin: &Admin, ) -> Result { - let key = Key::new(self.name.as_deref().unwrap_or("Unnamed key")); + let mut key = Key::new("Unnamed key"); + + apply_key_updates(&mut key, self.0); + garage.key_table.insert(&key).await?; Ok(CreateKeyResponse( @@ -149,21 +152,7 @@ impl RequestHandler for UpdateKeyRequest { ) -> Result { let mut key = garage.key_helper().get_existing_key(&self.id).await?; - let key_state = key.state.as_option_mut().unwrap(); - - if let Some(new_name) = self.body.name { - key_state.name.update(new_name); - } - if let Some(allow) = self.body.allow { - if allow.create_bucket { - key_state.allow_create_bucket.update(true); - } - } - if let Some(deny) = self.body.deny { - if deny.create_bucket { - key_state.allow_create_bucket.update(false); - } - } + apply_key_updates(&mut key, self.body); garage.key_table.insert(&key).await?; @@ -275,3 +264,26 @@ async fn key_info_results( Ok(res) } + +fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { + let key_state = key.state.as_option_mut().unwrap(); + + if let Some(new_name) = updates.name { + key_state.name.update(new_name); + } + if let Some(expiration) = updates.expiration { + key_state + .expiration + .update(Some(expiration.timestamp_millis() as u64)); + } + if let Some(allow) = updates.allow { + if allow.create_bucket { + key_state.allow_create_bucket.update(true); + } + } + if let Some(deny) = updates.deny { + if deny.create_bucket { + key_state.allow_create_bucket.update(false); + } + } +} diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 25937efa..d254f4e0 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::Local; +use chrono::{Local, Utc}; use garage_util::error::*; @@ -16,6 +16,7 @@ impl Cli { KeyOperation::Info(query) => self.cmd_key_info(query).await, KeyOperation::Create(query) => self.cmd_create_key(query).await, KeyOperation::Rename(query) => self.cmd_rename_key(query).await, + KeyOperation::Set(opt) => self.cmd_update_key(opt).await, KeyOperation::Delete(query) => self.cmd_delete_key(query).await, KeyOperation::Allow(query) => self.cmd_allow_key(query).await, KeyOperation::Deny(query) => self.cmd_deny_key(query).await, @@ -68,9 +69,17 @@ impl Cli { pub async fn cmd_create_key(&self, opt: KeyNewOpt) -> Result<(), Error> { let key = self - .api_request(CreateKeyRequest { + .api_request(CreateKeyRequest(UpdateKeyRequestBody { name: Some(opt.name), - }) + expiration: opt + .expires_in + .map(|x| parse_duration::parse::parse(&x)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter")? + .map(|dur| Utc::now() + dur), + allow: None, + deny: None, + })) .await?; print_key_info(&key.0); @@ -92,6 +101,38 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: Some(opt.new_name), + expiration: None, + allow: None, + deny: None, + }, + }) + .await?; + + print_key_info(&new_key.0); + + Ok(()) + } + + pub async fn cmd_update_key(&self, opt: KeySetOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: false, + }) + .await?; + + let new_key = self + .api_request(UpdateKeyRequest { + id: key.access_key_id, + body: UpdateKeyRequestBody { + name: None, + expiration: opt + .expires_in + .map(|x| parse_duration::parse::parse(&x)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter")? + .map(|dur| Utc::now() + dur), allow: None, deny: None, }, @@ -143,6 +184,7 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: None, + expiration: None, allow: Some(KeyPerm { create_bucket: opt.create_bucket, }), @@ -170,6 +212,7 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: None, + expiration: None, allow: None, deny: Some(KeyPerm { create_bucket: opt.create_bucket, diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 20079709..01a5d77f 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -426,6 +426,10 @@ pub enum KeyOperation { /// Import key #[structopt(name = "import", version = garage_version())] Import(KeyImportOpt), + + /// Set parameters for an access key + #[structopt(name = "set", version = garage_version())] + Set(KeySetOpt), } #[derive(StructOpt, Debug)] @@ -442,6 +446,21 @@ pub struct KeyNewOpt { /// Name of the key #[structopt(default_value = "Unnamed key")] pub name: String, + /// Set an expiration time for the access key + /// (see docs.rs/parse_duration for date format) + #[structopt(long = "expires-in")] + pub expires_in: Option, +} + +#[derive(StructOpt, Debug)] +pub struct KeySetOpt { + /// ID or name of the key + pub key_pattern: String, + + /// Set an expiration time for the access key + /// (see docs.rs/parse_duration for date format) + #[structopt(long = "expires-in")] + pub expires_in: Option, } #[derive(StructOpt, Debug)] From 5d338f0b8f857145229c5a5b570aa46d5e27d9c2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:44:09 +0200 Subject: [PATCH 108/192] add never_expires to remove expiration dates of admin tokens and access keys --- doc/api/garage-admin-v2.json | 8 ++++++++ src/api/admin/admin_token.rs | 20 +++++++++++++++++--- src/api/admin/api.rs | 6 ++++++ src/api/admin/key.rs | 17 ++++++++++++++--- src/garage/cli/remote/admin_token.rs | 3 +++ src/garage/cli/remote/key.rs | 5 +++++ src/garage/cli/structs.rs | 8 ++++++++ 7 files changed, 61 insertions(+), 6 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 4cc907d1..4e07ed68 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -4006,6 +4006,10 @@ ], "description": "Name of the admin API token" }, + "neverExpires": { + "type": "boolean", + "description": "Set the admin token to never expire" + }, "scope": { "type": [ "array", @@ -4137,6 +4141,10 @@ "null" ], "description": "Name of the API key" + }, + "neverExpires": { + "type": "boolean", + "description": "Set the access key to never expire" } } }, diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index b010dcf9..082d942a 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -124,7 +124,7 @@ impl RequestHandler for CreateAdminTokenRequest { AdminApiToken::new(&format!("token_{}", Utc::now().format("%Y%m%d_%H%M"))) }; - apply_token_updates(&mut token, self.0); + apply_token_updates(&mut token, self.0)?; garage.admin_token_table.insert(&token).await?; @@ -145,7 +145,7 @@ impl RequestHandler for UpdateAdminTokenRequest { ) -> Result { let mut token = get_existing_admin_token(&garage, &self.id).await?; - apply_token_updates(&mut token, self.body); + apply_token_updates(&mut token, self.body)?; garage.admin_token_table.insert(&token).await?; @@ -204,7 +204,16 @@ async fn get_existing_admin_token(garage: &Garage, id: &String) -> Result Result<(), Error> { + if updates.never_expires && updates.expiration.is_some() { + return Err(Error::bad_request( + "cannot specify `expiration` and `never_expires`", + )); + } + let params = token.params_mut().unwrap(); if let Some(name) = updates.name { @@ -215,7 +224,12 @@ fn apply_token_updates(token: &mut AdminApiToken, updates: UpdateAdminTokenReque .expiration .update(Some(expiration.timestamp_millis() as u64)); } + if updates.never_expires { + params.expiration.update(None); + } if let Some(scope) = updates.scope { params.scope.update(AdminApiTokenScope(scope)); } + + Ok(()) } diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index fa6c6b2d..1766ae28 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -366,6 +366,9 @@ pub struct UpdateAdminTokenRequestBody { pub name: Option, /// Expiration time and date, formatted according to RFC 3339 pub expiration: Option>, + /// Set the admin token to never expire + #[serde(default)] + pub never_expires: bool, /// Scope of the admin API token, a list of admin endpoint names (such as /// `GetClusterStatus`, etc), or the special value `*` to allow all /// admin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or @@ -737,6 +740,9 @@ pub struct UpdateKeyRequestBody { pub name: Option, /// Expiration time and date, formatted according to RFC 3339 pub expiration: Option>, + /// Set the access key to never expire + #[serde(default)] + pub never_expires: bool, /// Permissions to allow for the key pub allow: Option, /// Permissions to deny for the key diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 07373e76..7f0d819f 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -105,7 +105,7 @@ impl RequestHandler for CreateKeyRequest { ) -> Result { let mut key = Key::new("Unnamed key"); - apply_key_updates(&mut key, self.0); + apply_key_updates(&mut key, self.0)?; garage.key_table.insert(&key).await?; @@ -152,7 +152,7 @@ impl RequestHandler for UpdateKeyRequest { ) -> Result { let mut key = garage.key_helper().get_existing_key(&self.id).await?; - apply_key_updates(&mut key, self.body); + apply_key_updates(&mut key, self.body)?; garage.key_table.insert(&key).await?; @@ -265,7 +265,13 @@ async fn key_info_results( Ok(res) } -fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { +fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) -> Result<(), Error> { + if updates.never_expires && updates.expiration.is_some() { + return Err(Error::bad_request( + "cannot specify `expiration` and `never_expires`", + )); + } + let key_state = key.state.as_option_mut().unwrap(); if let Some(new_name) = updates.name { @@ -276,6 +282,9 @@ fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { .expiration .update(Some(expiration.timestamp_millis() as u64)); } + if updates.never_expires { + key_state.expiration.update(None); + } if let Some(allow) = updates.allow { if allow.create_bucket { key_state.allow_create_bucket.update(true); @@ -286,4 +295,6 @@ fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { key_state.allow_create_bucket.update(false); } } + + Ok(()) } diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index cd7ff9b0..83050c92 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -88,6 +88,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: false, scope: opt.scope.map(|s| { s.split(",") .map(|x| x.trim().to_string()) @@ -121,6 +122,7 @@ impl Cli { body: UpdateAdminTokenRequestBody { name: Some(new), expiration: None, + never_expires: false, scope: None, }, }) @@ -150,6 +152,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: opt.never_expires, scope: opt.scope.map({ let mut new_scope = token.scope; |scope_str| { diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index d254f4e0..6faede01 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -77,6 +77,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: false, allow: None, deny: None, })) @@ -102,6 +103,7 @@ impl Cli { body: UpdateKeyRequestBody { name: Some(opt.new_name), expiration: None, + never_expires: false, allow: None, deny: None, }, @@ -133,6 +135,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: opt.never_expires, allow: None, deny: None, }, @@ -185,6 +188,7 @@ impl Cli { body: UpdateKeyRequestBody { name: None, expiration: None, + never_expires: false, allow: Some(KeyPerm { create_bucket: opt.create_bucket, }), @@ -213,6 +217,7 @@ impl Cli { body: UpdateKeyRequestBody { name: None, expiration: None, + never_expires: false, allow: None, deny: Some(KeyPerm { create_bucket: opt.create_bucket, diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 01a5d77f..7c00aefc 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -461,6 +461,9 @@ pub struct KeySetOpt { /// (see docs.rs/parse_duration for date format) #[structopt(long = "expires-in")] pub expires_in: Option, + /// Set the access key to never expire + #[structopt(long = "never-expires")] + pub never_expires: bool, } #[derive(StructOpt, Debug)] @@ -587,10 +590,15 @@ pub struct AdminTokenCreateOp { pub struct AdminTokenSetOp { /// Name or prefix of the ID of the token to modify pub api_token: String, + /// Set an expiration time for the token (see docs.rs/parse_duration for date /// format) #[structopt(long = "expires-in")] pub expires_in: Option, + /// Set the token to never expire + #[structopt(long = "never-expires")] + pub never_expires: bool, + /// Set a limited scope for the token, as a comma-separated list of /// admin API functions (e.g. GetClusterStatus, etc.), or `*` to allow /// all admin API functions. From abcef7a3fd2440512fc84c0094099c20cfc1a4c9 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:58:19 +0200 Subject: [PATCH 109/192] cli: implement garage key delete-expired --- src/garage/cli/remote/key.rs | 24 ++++++++++++++++++++++++ src/garage/cli/structs.rs | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 6faede01..67df9c48 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -21,6 +21,7 @@ impl Cli { KeyOperation::Allow(query) => self.cmd_allow_key(query).await, KeyOperation::Deny(query) => self.cmd_deny_key(query).await, KeyOperation::Import(query) => self.cmd_import_key(query).await, + KeyOperation::DeleteExpired { yes } => self.cmd_delete_expired_keys(yes).await, } } @@ -248,6 +249,29 @@ impl Cli { Ok(()) } + + pub async fn cmd_delete_expired_keys(&self, yes: bool) -> Result<(), Error> { + let mut list = self.api_request(ListKeysRequest).await?.0; + + list.retain(|key| key.expired); + + if !yes { + return Err(Error::Message(format!( + "This would delete {} access keys, add the --yes flag to proceed.", + list.len(), + ))); + } + + for key in list.iter() { + let id = key.id.clone(); + println!("Deleting access key `{}` ({})", key.name, id); + self.api_request(DeleteKeyRequest { id }).await?; + } + + println!("{} access keys have been deleted.", list.len()); + + Ok(()) + } } fn print_key_info(key: &GetKeyInfoResponse) { diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 7c00aefc..fadfcc66 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -430,6 +430,14 @@ pub enum KeyOperation { /// Set parameters for an access key #[structopt(name = "set", version = garage_version())] Set(KeySetOpt), + + /// Delete all expired access keys + #[structopt(name = "delete-expired", version = garage_version())] + DeleteExpired { + /// Confirm deletion + #[structopt(long = "yes")] + yes: bool, + }, } #[derive(StructOpt, Debug)] From 52437e4298210b867dc9b0427fffb55f48fe3fe0 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 12:14:51 +0200 Subject: [PATCH 110/192] refactor parsing of --expires-in --- src/garage/cli/remote/admin_token.rs | 17 +++-------------- src/garage/cli/remote/key.rs | 16 +++------------- src/garage/cli/remote/mod.rs | 10 ++++++++++ 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 83050c92..6b2bd67e 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::{Local, Utc}; +use chrono::Local; use garage_util::error::*; @@ -78,16 +78,10 @@ impl Cli { } pub async fn cmd_create_admin_token(&self, opt: AdminTokenCreateOp) -> Result<(), Error> { - // TODO let res = self .api_request(CreateAdminTokenRequest(UpdateAdminTokenRequestBody { name: opt.name, - expiration: opt - .expires_in - .map(|x| parse_duration::parse::parse(&x)) - .transpose() - .ok_or_message("Invalid duration passed for --expires-in parameter")? - .map(|dur| Utc::now() + dur), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: false, scope: opt.scope.map(|s| { s.split(",") @@ -146,12 +140,7 @@ impl Cli { id: token.id.unwrap(), body: UpdateAdminTokenRequestBody { name: None, - expiration: opt - .expires_in - .map(|x| parse_duration::parse::parse(&x)) - .transpose() - .ok_or_message("Invalid duration passed for --expires-in parameter")? - .map(|dur| Utc::now() + dur), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: opt.never_expires, scope: opt.scope.map({ let mut new_scope = token.scope; diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 67df9c48..f448bb17 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::{Local, Utc}; +use chrono::Local; use garage_util::error::*; @@ -72,12 +72,7 @@ impl Cli { let key = self .api_request(CreateKeyRequest(UpdateKeyRequestBody { name: Some(opt.name), - expiration: opt - .expires_in - .map(|x| parse_duration::parse::parse(&x)) - .transpose() - .ok_or_message("Invalid duration passed for --expires-in parameter")? - .map(|dur| Utc::now() + dur), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: false, allow: None, deny: None, @@ -130,12 +125,7 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: None, - expiration: opt - .expires_in - .map(|x| parse_duration::parse::parse(&x)) - .transpose() - .ok_or_message("Invalid duration passed for --expires-in parameter")? - .map(|dur| Utc::now() + dur), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: opt.never_expires, allow: None, deny: None, diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs index af79157c..31cbdc6e 100644 --- a/src/garage/cli/remote/mod.rs +++ b/src/garage/cli/remote/mod.rs @@ -12,6 +12,8 @@ use std::convert::TryFrom; use std::sync::Arc; use std::time::Duration; +use chrono::{DateTime, Utc}; + use garage_util::error::*; use garage_rpc::*; @@ -162,3 +164,11 @@ pub fn table_list_abbr, S: AsRef>(values: T) -> S None => String::new(), } } + +pub fn parse_expires_in(expires_in: &Option) -> Result>, Error> { + expires_in + .as_ref() + .map(|x| parse_duration::parse::parse(&x).map(|dur| Utc::now() + dur)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter") +} From d38d62f4d798f0b18a952c937d3ae7ae51042557 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 12:36:41 +0200 Subject: [PATCH 111/192] bump version to v2.0.0 --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 24 ++++++++++++------------ doc/book/cookbook/real-world.md | 10 +++++----- doc/book/quick-start/_index.md | 2 +- script/helm/garage/Chart.yaml | 4 ++-- src/api/admin/Cargo.toml | 2 +- src/api/common/Cargo.toml | 2 +- src/api/k2v/Cargo.toml | 2 +- src/api/s3/Cargo.toml | 2 +- src/block/Cargo.toml | 2 +- src/db/Cargo.toml | 2 +- src/garage/Cargo.toml | 2 +- src/model/Cargo.toml | 2 +- src/net/Cargo.toml | 2 +- src/rpc/Cargo.toml | 2 +- src/rpc/system.rs | 2 +- src/table/Cargo.toml | 2 +- src/util/Cargo.toml | 2 +- src/web/Cargo.toml | 2 +- 19 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c66d7f8..2b9adcc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1200,7 +1200,7 @@ dependencies = [ [[package]] name = "garage" -version = "1.1.0" +version = "2.0.0" dependencies = [ "assert-json-diff", "async-trait", @@ -1255,7 +1255,7 @@ dependencies = [ [[package]] name = "garage_api_admin" -version = "1.1.0" +version = "2.0.0" dependencies = [ "argon2", "async-trait", @@ -1287,7 +1287,7 @@ dependencies = [ [[package]] name = "garage_api_common" -version = "1.1.0" +version = "2.0.0" dependencies = [ "base64 0.21.7", "bytes", @@ -1323,7 +1323,7 @@ dependencies = [ [[package]] name = "garage_api_k2v" -version = "1.1.0" +version = "2.0.0" dependencies = [ "base64 0.21.7", "err-derive", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "garage_api_s3" -version = "1.1.0" +version = "2.0.0" dependencies = [ "aes-gcm", "async-compression", @@ -1393,7 +1393,7 @@ dependencies = [ [[package]] name = "garage_block" -version = "1.1.0" +version = "2.0.0" dependencies = [ "arc-swap", "async-compression", @@ -1417,7 +1417,7 @@ dependencies = [ [[package]] name = "garage_db" -version = "1.1.0" +version = "2.0.0" dependencies = [ "err-derive", "heed", @@ -1430,7 +1430,7 @@ dependencies = [ [[package]] name = "garage_model" -version = "1.1.0" +version = "2.0.0" dependencies = [ "argon2", "async-trait", @@ -1458,7 +1458,7 @@ dependencies = [ [[package]] name = "garage_net" -version = "1.1.0" +version = "2.0.0" dependencies = [ "arc-swap", "bytes", @@ -1483,7 +1483,7 @@ dependencies = [ [[package]] name = "garage_rpc" -version = "1.1.0" +version = "2.0.0" dependencies = [ "arc-swap", "async-trait", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "garage_table" -version = "1.1.0" +version = "2.0.0" dependencies = [ "arc-swap", "async-trait", @@ -1536,7 +1536,7 @@ dependencies = [ [[package]] name = "garage_util" -version = "1.1.0" +version = "2.0.0" dependencies = [ "arc-swap", "async-trait", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "garage_web" -version = "1.1.0" +version = "2.0.0" dependencies = [ "err-derive", "garage_api_common", diff --git a/Cargo.toml b/Cargo.toml index 063e55ea..a7e3e225 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,18 +24,18 @@ default-members = ["src/garage"] # Internal Garage crates format_table = { version = "0.1.1", path = "src/format-table" } -garage_api_common = { version = "1.1.0", path = "src/api/common" } -garage_api_admin = { version = "1.1.0", path = "src/api/admin" } -garage_api_s3 = { version = "1.1.0", path = "src/api/s3" } -garage_api_k2v = { version = "1.1.0", path = "src/api/k2v" } -garage_block = { version = "1.1.0", path = "src/block" } -garage_db = { version = "1.1.0", path = "src/db", default-features = false } -garage_model = { version = "1.1.0", path = "src/model", default-features = false } -garage_net = { version = "1.1.0", path = "src/net" } -garage_rpc = { version = "1.1.0", path = "src/rpc" } -garage_table = { version = "1.1.0", path = "src/table" } -garage_util = { version = "1.1.0", path = "src/util" } -garage_web = { version = "1.1.0", path = "src/web" } +garage_api_common = { version = "2.0.0", path = "src/api/common" } +garage_api_admin = { version = "2.0.0", path = "src/api/admin" } +garage_api_s3 = { version = "2.0.0", path = "src/api/s3" } +garage_api_k2v = { version = "2.0.0", path = "src/api/k2v" } +garage_block = { version = "2.0.0", path = "src/block" } +garage_db = { version = "2.0.0", path = "src/db", default-features = false } +garage_model = { version = "2.0.0", path = "src/model", default-features = false } +garage_net = { version = "2.0.0", path = "src/net" } +garage_rpc = { version = "2.0.0", path = "src/rpc" } +garage_table = { version = "2.0.0", path = "src/table" } +garage_util = { version = "2.0.0", path = "src/util" } +garage_web = { version = "2.0.0", path = "src/web" } k2v-client = { version = "0.0.4", path = "src/k2v-client" } # External crates from crates.io diff --git a/doc/book/cookbook/real-world.md b/doc/book/cookbook/real-world.md index 594f1905..910c227b 100644 --- a/doc/book/cookbook/real-world.md +++ b/doc/book/cookbook/real-world.md @@ -96,14 +96,14 @@ to store 2 TB of data in total. ## Get a Docker image Our docker image is currently named `dxflrs/garage` and is stored on the [Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated). -We encourage you to use a fixed tag (eg. `v1.1.0`) and not the `latest` tag. -For this example, we will use the latest published version at the time of the writing which is `v1.1.0` but it's up to you +We encourage you to use a fixed tag (eg. `v2.0.0`) and not the `latest` tag. +For this example, we will use the latest published version at the time of the writing which is `v2.0.0` but it's up to you to check [the most recent versions on the Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated). For example: ``` -sudo docker pull dxflrs/garage:v1.1.0 +sudo docker pull dxflrs/garage:v2.0.0 ``` ## Deploying and configuring Garage @@ -171,7 +171,7 @@ docker run \ -v /etc/garage.toml:/etc/garage.toml \ -v /var/lib/garage/meta:/var/lib/garage/meta \ -v /var/lib/garage/data:/var/lib/garage/data \ - dxflrs/garage:v1.1.0 + dxflrs/garage:v2.0.0 ``` With this command line, Garage should be started automatically at each boot. @@ -185,7 +185,7 @@ If you want to use `docker-compose`, you may use the following `docker-compose.y version: "3" services: garage: - image: dxflrs/garage:v1.1.0 + image: dxflrs/garage:v2.0.0 network_mode: "host" restart: unless-stopped volumes: diff --git a/doc/book/quick-start/_index.md b/doc/book/quick-start/_index.md index 41867b19..b94257d5 100644 --- a/doc/book/quick-start/_index.md +++ b/doc/book/quick-start/_index.md @@ -132,7 +132,7 @@ docker run \ -v /etc/garage.toml:/path/to/garage.toml \ -v /var/lib/garage/meta:/path/to/garage/meta \ -v /var/lib/garage/data:/path/to/garage/data \ - dxflrs/garage:v1.1.0 + dxflrs/garage:v2.0.0 ``` Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903` diff --git a/script/helm/garage/Chart.yaml b/script/helm/garage/Chart.yaml index 1a3e27e0..5fe180a0 100644 --- a/script/helm/garage/Chart.yaml +++ b/script/helm/garage/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.7.0 +version: 0.8.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v1.1.0" +appVersion: "v2.0.0" diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 92d041cc..1b1fab13 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_admin" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index 5608a5e3..672df030 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_common" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/k2v/Cargo.toml b/src/api/k2v/Cargo.toml index 385aef3b..6e023b33 100644 --- a/src/api/k2v/Cargo.toml +++ b/src/api/k2v/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_k2v" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index e236729f..02866c35 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_s3" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/block/Cargo.toml b/src/block/Cargo.toml index dc13130b..58e9a5f2 100644 --- a/src/block/Cargo.toml +++ b/src/block/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_block" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index bfc9029c..1d839ccb 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_db" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index c8cd4a78..3d0734b4 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index a990a191..7c289c2a 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_model" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/net/Cargo.toml b/src/net/Cargo.toml index b48eb153..ccf24f44 100644 --- a/src/net/Cargo.toml +++ b/src/net/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_net" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/rpc/Cargo.toml b/src/rpc/Cargo.toml index e6466001..686abb7e 100644 --- a/src/rpc/Cargo.toml +++ b/src/rpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_rpc" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/rpc/system.rs b/src/rpc/system.rs index 800b37f3..97901130 100644 --- a/src/rpc/system.rs +++ b/src/rpc/system.rs @@ -45,7 +45,7 @@ const STATUS_EXCHANGE_INTERVAL: Duration = Duration::from_secs(10); /// Version tag used for version check upon Netapp connection. /// Cluster nodes with different version tags are deemed /// incompatible and will refuse to connect. -pub const GARAGE_VERSION_TAG: u64 = 0x6761726167650010; // garage 0x0010 (1.0) +pub const GARAGE_VERSION_TAG: u64 = 0x6761726167650020; // garage 0x0020 (2.0) /// RPC endpoint used for calls related to membership pub const SYSTEM_RPC_PATH: &str = "garage_rpc/system.rs/SystemRpc"; diff --git a/src/table/Cargo.toml b/src/table/Cargo.toml index ef7b44e4..9cb78369 100644 --- a/src/table/Cargo.toml +++ b/src/table/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_table" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/util/Cargo.toml b/src/util/Cargo.toml index 123406db..974bef78 100644 --- a/src/util/Cargo.toml +++ b/src/util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_util" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index c4fdbc0e..01a38039 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_web" -version = "1.1.0" +version = "2.0.0" authors = ["Alex Auvolat ", "Quentin Dufour "] edition = "2018" license = "AGPL-3.0" From e79b485aa8d8f39e2682404c29956f1904ce387e Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 17:38:20 +0200 Subject: [PATCH 112/192] fix panic in ListAdminTokens --- src/api/admin/admin_token.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index 082d942a..ac937eea 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -36,6 +36,20 @@ impl RequestHandler for ListAdminTokensRequest { .map(|t| admin_token_info_results(t, now)) .collect::>(); + if garage.config.admin.metrics_token.is_some() { + res.insert( + 0, + GetAdminTokenInfoResponse { + id: None, + created: None, + name: "metrics_token (from daemon configuration)".into(), + expiration: None, + expired: false, + scope: vec!["Metrics".into()], + }, + ); + } + if garage.config.admin.admin_token.is_some() { res.insert( 0, @@ -50,20 +64,6 @@ impl RequestHandler for ListAdminTokensRequest { ); } - if garage.config.admin.metrics_token.is_some() { - res.insert( - 1, - GetAdminTokenInfoResponse { - id: None, - created: None, - name: "metrics_token (from daemon configuration)".into(), - expiration: None, - expired: false, - scope: vec!["Metrics".into()], - }, - ); - } - Ok(ListAdminTokensResponse(res)) } } From 02498a93d0d5fee5540420345f87a7c4e44635b9 Mon Sep 17 00:00:00 2001 From: Zoob Date: Sat, 19 Apr 2025 18:46:36 +0000 Subject: [PATCH 113/192] doc: fix Docker run volume mappings --- doc/book/quick-start/_index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/book/quick-start/_index.md b/doc/book/quick-start/_index.md index 41867b19..2db4211b 100644 --- a/doc/book/quick-start/_index.md +++ b/doc/book/quick-start/_index.md @@ -129,9 +129,9 @@ docker run \ -d \ --name garaged \ -p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903 \ - -v /etc/garage.toml:/path/to/garage.toml \ - -v /var/lib/garage/meta:/path/to/garage/meta \ - -v /var/lib/garage/data:/path/to/garage/data \ + -v /path/to/garage.toml:/etc/garage.toml \ + -v /path/to/garage/meta:/var/lib/garage/meta \ + -v /path/to/garage/data:/var/lib/garage/data \ dxflrs/garage:v1.1.0 ``` From 9b38cba6f318192eefa8db871fc1367b197cfe6d Mon Sep 17 00:00:00 2001 From: babykart Date: Sat, 22 Mar 2025 20:44:45 +0100 Subject: [PATCH 114/192] helm-chart: Add livenessProbe & readinessProbe Signed-off-by: babykart --- script/helm/garage/README.md | 4 +++- script/helm/garage/templates/workload.yaml | 17 ++++++++--------- script/helm/garage/values.yaml | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/script/helm/garage/README.md b/script/helm/garage/README.md index c2eb086f..c9b54acd 100644 --- a/script/helm/garage/README.md +++ b/script/helm/garage/README.md @@ -1,6 +1,6 @@ # garage -![Version: 0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.0.1](https://img.shields.io/badge/AppVersion-v1.0.1-informational?style=flat-square) +![Version: 0.7.0](https://img.shields.io/badge/Version-0.7.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.1.0](https://img.shields.io/badge/AppVersion-v1.1.0-informational?style=flat-square) S3-compatible object store for small self-hosted geo-distributed deployments @@ -49,6 +49,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments | initImage.pullPolicy | string | `"IfNotPresent"` | | | initImage.repository | string | `"busybox"` | | | initImage.tag | string | `"stable"` | | +| livenessProbe | object | `{}` | Specifies a livenessProbe | | monitoring.metrics.enabled | bool | `false` | If true, a service for monitoring is created with a prometheus.io/scrape annotation | | monitoring.metrics.serviceMonitor.enabled | bool | `false` | If true, a ServiceMonitor CRD is created for a prometheus operator https://github.com/coreos/prometheus-operator | | monitoring.metrics.serviceMonitor.interval | string | `"15s"` | | @@ -71,6 +72,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments | podSecurityContext.runAsGroup | int | `1000` | | | podSecurityContext.runAsNonRoot | bool | `true` | | | podSecurityContext.runAsUser | int | `1000` | | +| readinessProbe | object | `{}` | Specifies a readinessProbe | | resources | object | `{}` | | | securityContext.capabilities | object | `{"drop":["ALL"]}` | The default security context is heavily restricted, feel free to tune it to your requirements | | securityContext.readOnlyRootFilesystem | bool | `true` | | diff --git a/script/helm/garage/templates/workload.yaml b/script/helm/garage/templates/workload.yaml index cb9e76a2..d144cb41 100644 --- a/script/helm/garage/templates/workload.yaml +++ b/script/helm/garage/templates/workload.yaml @@ -78,15 +78,14 @@ spec: {{- with .Values.extraVolumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} - # TODO - # livenessProbe: - # httpGet: - # path: / - # port: 3900 - # readinessProbe: - # httpGet: - # path: / - # port: 3900 + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} volumes: diff --git a/script/helm/garage/values.yaml b/script/helm/garage/values.yaml index 38715e38..0a6a45c5 100644 --- a/script/helm/garage/values.yaml +++ b/script/helm/garage/values.yaml @@ -191,6 +191,21 @@ resources: {} # cpu: 100m # memory: 512Mi +# -- Specifies a livenessProbe +livenessProbe: {} + #httpGet: + # path: /health + # port: 3903 + #initialDelaySeconds: 5 + #periodSeconds: 30 +# -- Specifies a readinessProbe +readinessProbe: {} + #httpGet: + # path: /health + # port: 3903 + #initialDelaySeconds: 5 + #periodSeconds: 30 + nodeSelector: {} tolerations: [] From e6e4e051a1a6b005e9baa4875e1a65b9d4b04dcb Mon Sep 17 00:00:00 2001 From: babykart Date: Sat, 22 Mar 2025 20:49:48 +0100 Subject: [PATCH 115/192] helm-chart: Add metadata_auto_snapshot_interval Signed-off-by: babykart --- script/helm/garage/README.md | 1 + script/helm/garage/templates/configmap.yaml | 4 ++++ script/helm/garage/values.yaml | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/script/helm/garage/README.md b/script/helm/garage/README.md index c9b54acd..1a187c84 100644 --- a/script/helm/garage/README.md +++ b/script/helm/garage/README.md @@ -23,6 +23,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments | garage.existingConfigMap | string | `""` | if not empty string, allow using an existing ConfigMap for the garage.toml, if set, ignores garage.toml | | garage.garageTomlString | string | `""` | String Template for the garage configuration if set, ignores above values. Values can be templated, see https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/ | | garage.kubernetesSkipCrd | bool | `false` | Set to true if you want to use k8s discovery but install the CRDs manually outside of the helm chart, for example if you operate at namespace level without cluster ressources | +| garage.metadataAutoSnapshotInterval | string | `""` | If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory. https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval | | garage.replicationMode | string | `"3"` | Default to 3 replicas, see the replication_mode section at https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#replication-mode | | garage.rpcBindAddr | string | `"[::]:3901"` | | | garage.rpcSecret | string | `""` | If not given, a random secret will be generated and stored in a Secret object | diff --git a/script/helm/garage/templates/configmap.yaml b/script/helm/garage/templates/configmap.yaml index 81ca205e..ab5b84db 100644 --- a/script/helm/garage/templates/configmap.yaml +++ b/script/helm/garage/templates/configmap.yaml @@ -19,6 +19,10 @@ data: compression_level = {{ .Values.garage.compressionLevel }} + {{- if .Values.garage.metadataAutoSnapshotInterval }} + metadata_auto_snapshot_interval = {{ .Values.garage.metadataAutoSnapshotInterval | quote }} + {{- end }} + rpc_bind_addr = "{{ .Values.garage.rpcBindAddr }}" # rpc_secret will be populated by the init container from a k8s secret object rpc_secret = "__RPC_SECRET_REPLACE__" diff --git a/script/helm/garage/values.yaml b/script/helm/garage/values.yaml index 0a6a45c5..bbb60db2 100644 --- a/script/helm/garage/values.yaml +++ b/script/helm/garage/values.yaml @@ -21,6 +21,10 @@ garage: # https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#compression-level compressionLevel: "1" + # -- If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory. + # https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval + metadataAutoSnapshotInterval: "" + rpcBindAddr: "[::]:3901" # -- If not given, a random secret will be generated and stored in a Secret object rpcSecret: "" From 3c20984a08528f1a6672c8afc83d2306a0361e40 Mon Sep 17 00:00:00 2001 From: babykart Date: Sat, 22 Mar 2025 20:52:47 +0100 Subject: [PATCH 116/192] helm-chart: Cosmetic changes Signed-off-by: babykart --- script/helm/garage/Chart.yaml | 30 ++++++++++++------------------ script/helm/garage/README.md | 6 ++++++ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/script/helm/garage/Chart.yaml b/script/helm/garage/Chart.yaml index 1a3e27e0..7a89409e 100644 --- a/script/helm/garage/Chart.yaml +++ b/script/helm/garage/Chart.yaml @@ -1,24 +1,18 @@ apiVersion: v2 name: garage description: S3-compatible object store for small self-hosted geo-distributed deployments - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) version: 0.7.0 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. appVersion: "v1.1.0" +home: https://garagehq.deuxfleurs.fr/ +icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg + +keywords: +- geo-distributed +- read-after-write-consistency +- s3-compatible + +sources: +- https://git.deuxfleurs.fr/Deuxfleurs/garage.git + +maintainers: [] \ No newline at end of file diff --git a/script/helm/garage/README.md b/script/helm/garage/README.md index 1a187c84..fcf988ca 100644 --- a/script/helm/garage/README.md +++ b/script/helm/garage/README.md @@ -4,6 +4,12 @@ S3-compatible object store for small self-hosted geo-distributed deployments +**Homepage:** + +## Source Code + +* + ## Values | Key | Type | Default | Description | From c8e9c4588937d5a206596e27486de7c90f1b7d27 Mon Sep 17 00:00:00 2001 From: Yureka Date: Sun, 20 Apr 2025 20:33:34 +0200 Subject: [PATCH 117/192] refactor: Use ReplicationFactor type in more places - Remove the replication_factor.replication_factor() in favor of usize::from(replication_factor) to make the conversion more explicit. - Implement Display on ReplicationFactor so that it can be formatted without converting to usize - Use ReplicationFactor in the constructor of LayoutVersion and add a method to get a ReplicationFactor from a LayoutVersion, despite LayoutVersion still storing it as usize internally. --- src/rpc/layout/manager.rs | 12 ++++++------ src/rpc/layout/version.rs | 9 +++++++-- src/rpc/replication_mode.rs | 14 ++++++++------ src/rpc/system_metrics.rs | 2 +- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/rpc/layout/manager.rs b/src/rpc/layout/manager.rs index 0c75742b..789603b5 100644 --- a/src/rpc/layout/manager.rs +++ b/src/rpc/layout/manager.rs @@ -46,11 +46,11 @@ impl LayoutManager { let cluster_layout = match persist_cluster_layout.load() { Ok(x) => { - if x.current().replication_factor != replication_factor.replication_factor() { + if x.current().replication_factor() != replication_factor { return Err(Error::Message(format!( "Previous cluster layout has replication factor {}, which is different than the one specified in the config file ({}). The previous cluster layout can be purged, if you know what you are doing, simply by deleting the `cluster_layout` file in your metadata directory.", - x.current().replication_factor, - replication_factor.replication_factor() + x.current().replication_factor(), + replication_factor, ))); } x @@ -301,11 +301,11 @@ impl LayoutManager { adv.update_trackers ); - if adv.current().replication_factor != self.replication_factor.replication_factor() { + if adv.current().replication_factor() != self.replication_factor { let msg = format!( "Received a cluster layout from another node with replication factor {}, which is different from what we have in our configuration ({}). Discarding the cluster layout we received.", - adv.current().replication_factor, - self.replication_factor.replication_factor() + adv.current().replication_factor(), + self.replication_factor, ); error!("{}", msg); return Err(Error::Message(msg)); diff --git a/src/rpc/layout/version.rs b/src/rpc/layout/version.rs index fdcccc46..6036cfe4 100644 --- a/src/rpc/layout/version.rs +++ b/src/rpc/layout/version.rs @@ -11,12 +11,13 @@ use garage_util::error::*; use super::graph_algo::*; use super::*; +use crate::replication_mode::*; // The Message type will be used to collect information on the algorithm. pub type Message = Vec; impl LayoutVersion { - pub fn new(replication_factor: usize) -> Self { + pub fn new(replication_factor: ReplicationFactor) -> Self { // We set the default zone redundancy to be Maximum, meaning that the maximum // possible value will be used depending on the cluster topology let parameters = LayoutParameters { @@ -25,7 +26,7 @@ impl LayoutVersion { LayoutVersion { version: 0, - replication_factor, + replication_factor: usize::from(replication_factor), partition_size: 0, roles: LwwMap::new(), node_id_vec: Vec::new(), @@ -132,6 +133,10 @@ impl LayoutVersion { .map(move |i| self.node_id_vec[*i as usize]) } + pub fn replication_factor(&self) -> ReplicationFactor { + ReplicationFactor::new(self.replication_factor).unwrap() + } + // ===================== internal information extractors ====================== pub(crate) fn expect_get_node_capacity(&self, uuid: &Uuid) -> u64 { diff --git a/src/rpc/replication_mode.rs b/src/rpc/replication_mode.rs index a3a94085..caf67462 100644 --- a/src/rpc/replication_mode.rs +++ b/src/rpc/replication_mode.rs @@ -38,14 +38,10 @@ impl ReplicationFactor { } } - pub fn replication_factor(&self) -> usize { - self.0 - } - pub fn read_quorum(&self, consistency_mode: ConsistencyMode) -> usize { match consistency_mode { ConsistencyMode::Dangerous | ConsistencyMode::Degraded => 1, - ConsistencyMode::Consistent => self.replication_factor().div_ceil(2), + ConsistencyMode::Consistent => usize::from(*self).div_ceil(2), } } @@ -53,7 +49,7 @@ impl ReplicationFactor { match consistency_mode { ConsistencyMode::Dangerous => 1, ConsistencyMode::Degraded | ConsistencyMode::Consistent => { - (self.replication_factor() + 1) - self.read_quorum(ConsistencyMode::Consistent) + (usize::from(*self) + 1) - self.read_quorum(ConsistencyMode::Consistent) } } } @@ -65,6 +61,12 @@ impl std::convert::From for usize { } } +impl std::fmt::Display for ReplicationFactor { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.fmt(f) + } +} + pub fn parse_replication_mode( config: &Config, ) -> Result<(ReplicationFactor, ConsistencyMode), Error> { diff --git a/src/rpc/system_metrics.rs b/src/rpc/system_metrics.rs index a64daec8..31c2e9ff 100644 --- a/src/rpc/system_metrics.rs +++ b/src/rpc/system_metrics.rs @@ -68,7 +68,7 @@ impl SystemMetrics { let replication_factor = system.replication_factor; meter .u64_value_observer("garage_replication_factor", move |observer| { - observer.observe(replication_factor.replication_factor() as u64, &[]) + observer.observe(usize::from(replication_factor) as u64, &[]) }) .with_description("Garage replication factor setting") .init() From ad151cb1dc2c657db4c969a306349bc077ed648a Mon Sep 17 00:00:00 2001 From: "Maximilien R." Date: Wed, 23 Apr 2025 23:30:16 +0200 Subject: [PATCH 118/192] Fix #1007: hint that region can be changed depending on cluster config --- doc/book/connect/apps/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/book/connect/apps/index.md b/doc/book/connect/apps/index.md index 14868373..5ec9686c 100644 --- a/doc/book/connect/apps/index.md +++ b/doc/book/connect/apps/index.md @@ -69,7 +69,7 @@ $CONFIG = array( 'hostname' => '127.0.0.1', // Can also be a domain name, eg. garage.example.com 'port' => 3900, // Put your reverse proxy port or your S3 API port 'use_ssl' => false, // Set it to true if you have a TLS enabled reverse proxy - 'region' => 'garage', // Garage has only one region named "garage" + 'region' => 'garage', // Garage default region is named "garage", edit according to your cluster config 'use_path_style' => true // Garage supports only path style, must be set to true ], ], @@ -135,7 +135,7 @@ bucket but doesn't also know the secret encryption key. *Click on the picture to zoom* Add a new external storage. Put what you want in "folder name" (eg. "shared"). Select "Amazon S3". Keep "Access Key" for the Authentication field. -In Configuration, put your bucket name (eg. nextcloud), the host (eg. 127.0.0.1), the port (eg. 3900 or 443), the region (garage). Tick the SSL box if you have put an HTTPS proxy in front of garage. You must tick the "Path access" box and you must leave the "Legacy authentication (v2)" box empty. Put your Key ID (eg. GK...) and your Secret Key in the last two input boxes. Finally click on the tick symbol on the right of your screen. +In Configuration, put your bucket name (eg. nextcloud), the host (eg. 127.0.0.1), the port (eg. 3900 or 443), the region ("garage" if you use the default, or the one your configured in your `garage.toml`). Tick the SSL box if you have put an HTTPS proxy in front of garage. You must tick the "Path access" box and you must leave the "Legacy authentication (v2)" box empty. Put your Key ID (eg. GK...) and your Secret Key in the last two input boxes. Finally click on the tick symbol on the right of your screen. Now go to your "Files" app and a new "linked folder" has appeared with the name you chose earlier (eg. "shared"). @@ -238,7 +238,7 @@ object_storage: # Put localhost only if you have a garage instance running on that node endpoint: 'http://localhost:3900' # or "garage.example.com" if you have TLS on port 443 - # Garage supports only one region for now, named garage + # Garage default region is named "garage", edit according to your config region: 'garage' credentials: @@ -441,7 +441,7 @@ media_storage_providers: store_synchronous: True # do we want to wait that the file has been written before returning? config: bucket: matrix # the name of our bucket, we chose matrix earlier - region_name: garage # only "garage" is supported for the region field + region_name: garage # "garage" by default, edit according to your cluster config endpoint_url: http://localhost:3900 # the path to the S3 endpoint access_key_id: "GKxxx" # your Key ID secret_access_key: "xxxx" # your Secret Key From 899292ee28030347004e63890dda6f0f72b4bd35 Mon Sep 17 00:00:00 2001 From: Yureka Date: Sun, 20 Apr 2025 20:37:52 +0200 Subject: [PATCH 119/192] refactor: make TableShardedReplication a thin wrapper around LayoutManager --- src/model/garage.rs | 6 ++--- src/rpc/layout/version.rs | 8 +++++++ src/table/replication/sharded.rs | 39 ++++++++++++++++---------------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/model/garage.rs b/src/model/garage.rs index a7e0b62b..46d78acf 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -155,10 +155,8 @@ impl Garage { let system = System::new(network_key, replication_factor, consistency_mode, &config)?; let meta_rep_param = TableShardedReplication { - system: system.clone(), - replication_factor: replication_factor.into(), - write_quorum: replication_factor.write_quorum(consistency_mode), - read_quorum: replication_factor.read_quorum(consistency_mode), + layout_manager: system.layout_manager.clone(), + consistency_mode, }; let control_rep_param = TableFullReplication { diff --git a/src/rpc/layout/version.rs b/src/rpc/layout/version.rs index 6036cfe4..4dd2963c 100644 --- a/src/rpc/layout/version.rs +++ b/src/rpc/layout/version.rs @@ -137,6 +137,14 @@ impl LayoutVersion { ReplicationFactor::new(self.replication_factor).unwrap() } + pub fn read_quorum(&self, consistency_mode: ConsistencyMode) -> usize { + self.replication_factor().read_quorum(consistency_mode) + } + + pub fn write_quorum(&self, consistency_mode: ConsistencyMode) -> usize { + self.replication_factor().write_quorum(consistency_mode) + } + // ===================== internal information extractors ====================== pub(crate) fn expect_get_node_capacity(&self, uuid: &Uuid) -> u64 { diff --git a/src/table/replication/sharded.rs b/src/table/replication/sharded.rs index 2514d880..cd2e8044 100644 --- a/src/table/replication/sharded.rs +++ b/src/table/replication/sharded.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use std::time::Duration; use garage_rpc::layout::*; -use garage_rpc::system::System; +use garage_rpc::replication_mode::ConsistencyMode; use garage_util::data::*; +use crate::replication::sharded::manager::LayoutManager; use crate::replication::*; /// Sharded replication schema: @@ -16,13 +17,8 @@ use crate::replication::*; #[derive(Clone)] pub struct TableShardedReplication { /// The membership manager of this node - pub system: Arc, - /// How many time each data should be replicated - pub replication_factor: usize, - /// How many nodes to contact for a read, should be at most `replication_factor` - pub read_quorum: usize, - /// How many nodes to contact for a write, should be at most `replication_factor` - pub write_quorum: usize, + pub layout_manager: Arc, + pub consistency_mode: ConsistencyMode, } impl TableReplication for TableShardedReplication { @@ -32,9 +28,8 @@ impl TableReplication for TableShardedReplication { type WriteSets = WriteLock>>; fn storage_nodes(&self, hash: &Hash) -> Vec { - let layout = self.system.cluster_layout(); let mut ret = vec![]; - for version in layout.versions().iter() { + for version in self.layout_manager.layout().versions().iter() { ret.extend(version.nodes_of(hash)); } ret.sort(); @@ -43,31 +38,37 @@ impl TableReplication for TableShardedReplication { } fn read_nodes(&self, hash: &Hash) -> Vec { - self.system - .cluster_layout() + self.layout_manager + .layout() .read_version() .nodes_of(hash) .collect() } + fn read_quorum(&self) -> usize { - self.read_quorum + self.layout_manager + .layout() + .read_version() + .read_quorum(self.consistency_mode) } fn write_sets(&self, hash: &Hash) -> Self::WriteSets { - self.system - .layout_manager - .write_lock_with(|l| write_sets(l, hash)) + self.layout_manager.write_lock_with(|l| write_sets(l, hash)) } + fn write_quorum(&self) -> usize { - self.write_quorum + self.layout_manager + .layout() + .current() + .write_quorum(self.consistency_mode) } fn partition_of(&self, hash: &Hash) -> Partition { - self.system.cluster_layout().current().partition_of(hash) + self.layout_manager.layout().current().partition_of(hash) } fn sync_partitions(&self) -> SyncPartitions { - let layout = self.system.cluster_layout(); + let layout = self.layout_manager.layout(); let layout_version = layout.ack_map_min(); let mut partitions = layout From a2d87a012d2452177da37d159f1367f17eba7d9c Mon Sep 17 00:00:00 2001 From: Yureka Date: Sun, 20 Apr 2025 20:56:04 +0200 Subject: [PATCH 120/192] refactor: use replication factor of the layout versions in calculate_sync_map_min_with_quorum --- src/rpc/layout/helper.rs | 7 +------ src/rpc/layout/history.rs | 26 +++++++++++++++----------- src/rpc/layout/manager.rs | 8 ++------ 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/rpc/layout/helper.rs b/src/rpc/layout/helper.rs index 35746851..088ffb2f 100644 --- a/src/rpc/layout/helper.rs +++ b/src/rpc/layout/helper.rs @@ -28,7 +28,6 @@ pub struct SyncLayoutDigest { } pub struct LayoutHelper { - replication_factor: ReplicationFactor, consistency_mode: ConsistencyMode, layout: Option, @@ -51,7 +50,6 @@ pub struct LayoutHelper { impl LayoutHelper { pub fn new( - replication_factor: ReplicationFactor, consistency_mode: ConsistencyMode, mut layout: LayoutHistory, mut ack_lock: HashMap, @@ -97,8 +95,7 @@ impl LayoutHelper { // consistency on those). // This value is calculated using quorums to allow progress even // if not all nodes have successfully completed a sync. - let sync_map_min = - layout.calculate_sync_map_min_with_quorum(replication_factor, &all_nongateway_nodes); + let sync_map_min = layout.calculate_sync_map_min_with_quorum(&all_nongateway_nodes); let trackers_hash = layout.calculate_trackers_hash(); let staging_hash = layout.calculate_staging_hash(); @@ -111,7 +108,6 @@ impl LayoutHelper { let is_check_ok = layout.check().is_ok(); LayoutHelper { - replication_factor, consistency_mode, layout: Some(layout), ack_map_min, @@ -134,7 +130,6 @@ impl LayoutHelper { let changed = f(self.layout.as_mut().unwrap()); if changed { *self = Self::new( - self.replication_factor, self.consistency_mode, self.layout.take().unwrap(), std::mem::take(&mut self.ack_lock), diff --git a/src/rpc/layout/history.rs b/src/rpc/layout/history.rs index 1e6bc84b..79f4e3c0 100644 --- a/src/rpc/layout/history.rs +++ b/src/rpc/layout/history.rs @@ -123,13 +123,9 @@ impl LayoutHistory { } } - pub(crate) fn calculate_sync_map_min_with_quorum( - &self, - replication_factor: ReplicationFactor, - all_nongateway_nodes: &[Uuid], - ) -> u64 { - // This function calculates the minimum layout version from which - // it is safe to read if we want to maintain read-after-write consistency. + /// This function calculates the minimum layout version from which + /// it is safe to read if we want to maintain read-after-write consistency. + pub(crate) fn calculate_sync_map_min_with_quorum(&self, all_nongateway_nodes: &[Uuid]) -> u64 { // In the general case the computation can be a bit expensive so // we try to optimize it in several ways. @@ -139,8 +135,6 @@ impl LayoutHistory { return self.current().version; } - let quorum = replication_factor.write_quorum(ConsistencyMode::Consistent); - let min_version = self.min_stored(); let global_min = self .update_trackers @@ -153,7 +147,16 @@ impl LayoutHistory { // This is represented by reading from the layout with version // number global_min, the smallest layout version for which all nodes // have completed a sync. - if quorum == self.current().replication_factor { + // + // While we currently do not support changing the replication factor + // between layout versions, this calculation is future-proofing for the + // case where this might be possible. + if self + .versions + .iter() + .filter(|v| v.version >= global_min) + .all(|v| v.write_quorum(ConsistencyMode::Consistent) == v.replication_factor) + { return global_min; } @@ -195,7 +198,8 @@ impl LayoutHistory { .map(|x| self.update_trackers.sync_map.get(x, min_version)) .collect::>(); sync_values.sort(); - let set_min = sync_values[sync_values.len() - quorum]; + let set_min = + sync_values[sync_values.len() - v.write_quorum(ConsistencyMode::Consistent)]; if set_min < current_min { current_min = set_min; } diff --git a/src/rpc/layout/manager.rs b/src/rpc/layout/manager.rs index 789603b5..c5bba0b6 100644 --- a/src/rpc/layout/manager.rs +++ b/src/rpc/layout/manager.rs @@ -64,12 +64,8 @@ impl LayoutManager { } }; - let mut cluster_layout = LayoutHelper::new( - replication_factor, - consistency_mode, - cluster_layout, - Default::default(), - ); + let mut cluster_layout = + LayoutHelper::new(consistency_mode, cluster_layout, Default::default()); cluster_layout.update_update_trackers(node_id.into()); let layout = Arc::new(RwLock::new(cluster_layout)); From 14274bc13c2bc39ad54c3a36f5c6473897762009 Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Thu, 8 May 2025 10:27:53 +0200 Subject: [PATCH 121/192] doc: Add systemd example to increase file descriptors limit --- doc/book/cookbook/systemd.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/book/cookbook/systemd.md b/doc/book/cookbook/systemd.md index c0ed7d1f..ebff8c15 100644 --- a/doc/book/cookbook/systemd.md +++ b/doc/book/cookbook/systemd.md @@ -28,6 +28,7 @@ StateDirectory=garage DynamicUser=true ProtectHome=true NoNewPrivileges=true +LimitNOFILE=42000 [Install] WantedBy=multi-user.target From 539af12d21567a39a074d3a73c893d98275c70d4 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Mon, 19 May 2025 18:07:04 +0200 Subject: [PATCH 122/192] allow punnycode in bucket name --- src/api/admin/bucket.rs | 4 ++-- src/api/s3/bucket.rs | 2 +- src/garage/admin/bucket.rs | 2 +- src/model/bucket_alias_table.rs | 18 ++++++++---------- src/model/helper/locked.rs | 7 +++---- src/util/config.rs | 4 ++++ 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 2537bfc9..6cc21938 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -277,7 +277,7 @@ pub async fn handle_create_bucket( let helper = garage.locked_helper().await; if let Some(ga) = &req.global_alias { - if !is_valid_bucket_name(ga) { + if !is_valid_bucket_name(ga, garage.config.allow_punnycode) { return Err(Error::bad_request(format!( "{}: {}", ga, INVALID_BUCKET_NAME_MESSAGE @@ -292,7 +292,7 @@ pub async fn handle_create_bucket( } if let Some(la) = &req.local_alias { - if !is_valid_bucket_name(&la.alias) { + if !is_valid_bucket_name(&la.alias, garage.config.allow_punnycode) { return Err(Error::bad_request(format!( "{}: {}", la.alias, INVALID_BUCKET_NAME_MESSAGE diff --git a/src/api/s3/bucket.rs b/src/api/s3/bucket.rs index 3a09e769..d2a36c18 100644 --- a/src/api/s3/bucket.rs +++ b/src/api/s3/bucket.rs @@ -172,7 +172,7 @@ pub async fn handle_create_bucket( } // Create the bucket! - if !is_valid_bucket_name(&bucket_name) { + if !is_valid_bucket_name(&bucket_name, garage.config.allow_punnycode) { return Err(Error::bad_request(format!( "{}: {}", bucket_name, INVALID_BUCKET_NAME_MESSAGE diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs index 1bdc6086..1ed0ebd8 100644 --- a/src/garage/admin/bucket.rs +++ b/src/garage/admin/bucket.rs @@ -126,7 +126,7 @@ impl AdminRpcHandler { #[allow(clippy::ptr_arg)] async fn handle_create_bucket(&self, name: &String) -> Result { - if !is_valid_bucket_name(name) { + if !is_valid_bucket_name(name, self.garage.config.allow_punnycode) { return Err(Error::BadRequest(format!( "{}: {}", name, INVALID_BUCKET_NAME_MESSAGE diff --git a/src/model/bucket_alias_table.rs b/src/model/bucket_alias_table.rs index 8bbe4118..04d808e8 100644 --- a/src/model/bucket_alias_table.rs +++ b/src/model/bucket_alias_table.rs @@ -22,14 +22,10 @@ mod v08 { pub use v08::*; impl BucketAlias { - pub fn new(name: String, ts: u64, bucket_id: Option) -> Option { - if !is_valid_bucket_name(&name) { - None - } else { - Some(BucketAlias { - name, - state: crdt::Lww::raw(ts, bucket_id), - }) + pub fn new(name: String, ts: u64, bucket_id: Option) -> Self { + BucketAlias { + name, + state: crdt::Lww::raw(ts, bucket_id), } } @@ -80,7 +76,7 @@ impl TableSchema for BucketAliasTable { /// In the case of Garage, bucket names must not be hex-encoded /// 32 byte string, which is excluded thanks to the /// maximum length of 63 bytes given in the spec. -pub fn is_valid_bucket_name(n: &str) -> bool { +pub fn is_valid_bucket_name(n: &str, punny: bool) -> bool { // Bucket names must be between 3 and 63 characters n.len() >= 3 && n.len() <= 63 // Bucket names must be composed of lowercase letters, numbers, @@ -92,7 +88,9 @@ pub fn is_valid_bucket_name(n: &str) -> bool { // Bucket names must not be formatted as an IP address && n.parse::().is_err() // Bucket names must not start with "xn--" - && !n.starts_with("xn--") + && (!n.starts_with("xn--") || punny) + // We are a bit stricter, to properly restrict punnycode in all labels + && (!n.contains(".xn--") || punny) // Bucket names must not end with "-s3alias" && !n.ends_with("-s3alias") } diff --git a/src/model/helper/locked.rs b/src/model/helper/locked.rs index 482e91b0..16b0bafc 100644 --- a/src/model/helper/locked.rs +++ b/src/model/helper/locked.rs @@ -57,7 +57,7 @@ impl<'a> LockedHelper<'a> { bucket_id: Uuid, alias_name: &String, ) -> Result<(), Error> { - if !is_valid_bucket_name(alias_name) { + if !is_valid_bucket_name(alias_name, self.0.config.allow_punnycode) { return Err(Error::InvalidBucketName(alias_name.to_string())); } @@ -88,8 +88,7 @@ impl<'a> LockedHelper<'a> { // writes are now done and all writes use timestamp alias_ts let alias = match alias { - None => BucketAlias::new(alias_name.clone(), alias_ts, Some(bucket_id)) - .ok_or_else(|| Error::InvalidBucketName(alias_name.clone()))?, + None => BucketAlias::new(alias_name.clone(), alias_ts, Some(bucket_id)), Some(mut a) => { a.state = Lww::raw(alias_ts, Some(bucket_id)); a @@ -218,7 +217,7 @@ impl<'a> LockedHelper<'a> { ) -> Result<(), Error> { let key_helper = KeyHelper(self.0); - if !is_valid_bucket_name(alias_name) { + if !is_valid_bucket_name(alias_name, self.0.config.allow_punnycode) { return Err(Error::InvalidBucketName(alias_name.to_string())); } diff --git a/src/util/config.rs b/src/util/config.rs index 73fc4ff4..f128177b 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -135,6 +135,10 @@ pub struct Config { /// Configuration for the admin API endpoint #[serde(default = "Default::default")] pub admin: AdminConfig, + + /// Allow punnycode in bucket names + #[serde(default)] + pub allow_punnycode: bool, } /// Value for data_dir: either a single directory or a list of dirs with attributes From a605a8080659b73939f6b3ff60bc0847ed0fb3c5 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Mon, 19 May 2025 18:11:55 +0200 Subject: [PATCH 123/192] support punnycode in api/web endpoint --- Cargo.lock | 43 +-------------------------------------- Cargo.toml | 1 - src/api/common/Cargo.toml | 1 - src/api/common/helpers.rs | 3 +-- 4 files changed, 2 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd5a1f9f..e65778cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1300,7 +1300,6 @@ dependencies = [ "http-body-util", "hyper 1.6.0", "hyper-util", - "idna 0.5.0", "md-5", "nom", "opentelemetry", @@ -2170,16 +2169,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "1.0.3" @@ -4252,21 +4241,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.44.1" @@ -4587,27 +4561,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -4655,7 +4614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna 1.0.3", + "idna", "percent-encoding", ] diff --git a/Cargo.toml b/Cargo.toml index 732f6f05..400c1840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,6 @@ git-version = "0.3.4" hex = "0.4" hexdump = "0.1" hmac = "0.12" -idna = "0.5" itertools = "0.12" ipnet = "2.9.0" lazy_static = "1.4" diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index 6d906423..b1a8b47a 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -28,7 +28,6 @@ err-derive.workspace = true hex.workspace = true hmac.workspace = true md-5.workspace = true -idna.workspace = true tracing.workspace = true nom.workspace = true pin-project.workspace = true diff --git a/src/api/common/helpers.rs b/src/api/common/helpers.rs index c8586de4..6fc4aa13 100644 --- a/src/api/common/helpers.rs +++ b/src/api/common/helpers.rs @@ -8,7 +8,6 @@ use hyper::{ body::{Body, Bytes}, Request, Response, }; -use idna::domain_to_unicode; use serde::{Deserialize, Serialize}; use garage_model::bucket_table::BucketParams; @@ -97,7 +96,7 @@ pub fn authority_to_host(authority: &str) -> Result { authority ))), }; - authority.map(|h| domain_to_unicode(h).0) + authority.map(|h| h.to_ascii_lowercase()) } /// Extract the bucket name and the key name from an HTTP path and possibly a bucket provided in From bba9202f310b257ed52d1a82052f05532495c62e Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Mon, 19 May 2025 20:36:03 +0200 Subject: [PATCH 124/192] add test for punycode --- src/api/admin/bucket.rs | 4 +- src/api/s3/bucket.rs | 2 +- src/garage/admin/bucket.rs | 2 +- src/garage/tests/common/garage.rs | 2 + src/garage/tests/s3/website.rs | 73 +++++++++++++++++++++++++++++++ src/model/bucket_alias_table.rs | 8 ++-- src/model/helper/locked.rs | 4 +- src/util/config.rs | 4 +- 8 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 6cc21938..f8bd1eb5 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -277,7 +277,7 @@ pub async fn handle_create_bucket( let helper = garage.locked_helper().await; if let Some(ga) = &req.global_alias { - if !is_valid_bucket_name(ga, garage.config.allow_punnycode) { + if !is_valid_bucket_name(ga, garage.config.allow_punycode) { return Err(Error::bad_request(format!( "{}: {}", ga, INVALID_BUCKET_NAME_MESSAGE @@ -292,7 +292,7 @@ pub async fn handle_create_bucket( } if let Some(la) = &req.local_alias { - if !is_valid_bucket_name(&la.alias, garage.config.allow_punnycode) { + if !is_valid_bucket_name(&la.alias, garage.config.allow_punycode) { return Err(Error::bad_request(format!( "{}: {}", la.alias, INVALID_BUCKET_NAME_MESSAGE diff --git a/src/api/s3/bucket.rs b/src/api/s3/bucket.rs index d2a36c18..23cceb84 100644 --- a/src/api/s3/bucket.rs +++ b/src/api/s3/bucket.rs @@ -172,7 +172,7 @@ pub async fn handle_create_bucket( } // Create the bucket! - if !is_valid_bucket_name(&bucket_name, garage.config.allow_punnycode) { + if !is_valid_bucket_name(&bucket_name, garage.config.allow_punycode) { return Err(Error::bad_request(format!( "{}: {}", bucket_name, INVALID_BUCKET_NAME_MESSAGE diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs index 1ed0ebd8..073329c1 100644 --- a/src/garage/admin/bucket.rs +++ b/src/garage/admin/bucket.rs @@ -126,7 +126,7 @@ impl AdminRpcHandler { #[allow(clippy::ptr_arg)] async fn handle_create_bucket(&self, name: &String) -> Result { - if !is_valid_bucket_name(name, self.garage.config.allow_punnycode) { + if !is_valid_bucket_name(name, self.garage.config.allow_punycode) { return Err(Error::BadRequest(format!( "{}: {}", name, INVALID_BUCKET_NAME_MESSAGE diff --git a/src/garage/tests/common/garage.rs b/src/garage/tests/common/garage.rs index 8d71504f..2b0a381c 100644 --- a/src/garage/tests/common/garage.rs +++ b/src/garage/tests/common/garage.rs @@ -63,6 +63,8 @@ rpc_bind_addr = "127.0.0.1:{rpc_port}" rpc_public_addr = "127.0.0.1:{rpc_port}" rpc_secret = "{secret}" +allow_punycode = true + [s3_api] s3_region = "{region}" api_bind_addr = "127.0.0.1:{s3_port}" diff --git a/src/garage/tests/s3/website.rs b/src/garage/tests/s3/website.rs index 9a9e29f2..6d37eee8 100644 --- a/src/garage/tests/s3/website.rs +++ b/src/garage/tests/s3/website.rs @@ -533,3 +533,76 @@ async fn test_website_check_domain() { }) ); } + +#[tokio::test] +async fn test_website_puny() { + const BCKT_NAME: &str = "xn--pda.eu"; + let ctx = common::context(); + let bucket = ctx.create_bucket(BCKT_NAME); + + let data = ByteStream::from_static(BODY); + + ctx.client + .put_object() + .bucket(&bucket) + .key("index.html") + .body(data) + .send() + .await + .unwrap(); + + let client = Client::builder(TokioExecutor::new()).build_http(); + + let req = |suffix| { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/", ctx.garage.web_port)) + .header("Host", format!("{}{}", BCKT_NAME, suffix)) + .body(Body::new(Bytes::new())) + .unwrap() + }; + + ctx.garage + .command() + .args(["bucket", "website", "--allow", BCKT_NAME]) + .quiet() + .expect_success_status("Could not allow website on bucket"); + + let mut resp = client.request(req("")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + BODY.as_ref() + ); + + resp = client.request(req(".web.garage")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.into_body().collect().await.unwrap().to_bytes(), + BODY.as_ref() + ); + + for bname in [ + BCKT_NAME.to_string(), + format!("{BCKT_NAME}.web.garage"), + format!("{BCKT_NAME}.s3.garage"), + ] { + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!( + "http://127.0.0.1:{0}/check?domain={1}", + ctx.garage.admin_port, bname + )) + .body(Body::new(Bytes::new())) + .unwrap() + }; + + let admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::OK); + assert_eq!( + admin_resp.into_body().collect().await.unwrap().to_bytes(), + format!("Domain '{bname}' is managed by Garage").as_bytes() + ); + } +} diff --git a/src/model/bucket_alias_table.rs b/src/model/bucket_alias_table.rs index 04d808e8..276d0d1c 100644 --- a/src/model/bucket_alias_table.rs +++ b/src/model/bucket_alias_table.rs @@ -76,7 +76,7 @@ impl TableSchema for BucketAliasTable { /// In the case of Garage, bucket names must not be hex-encoded /// 32 byte string, which is excluded thanks to the /// maximum length of 63 bytes given in the spec. -pub fn is_valid_bucket_name(n: &str, punny: bool) -> bool { +pub fn is_valid_bucket_name(n: &str, puny: bool) -> bool { // Bucket names must be between 3 and 63 characters n.len() >= 3 && n.len() <= 63 // Bucket names must be composed of lowercase letters, numbers, @@ -88,9 +88,9 @@ pub fn is_valid_bucket_name(n: &str, punny: bool) -> bool { // Bucket names must not be formatted as an IP address && n.parse::().is_err() // Bucket names must not start with "xn--" - && (!n.starts_with("xn--") || punny) - // We are a bit stricter, to properly restrict punnycode in all labels - && (!n.contains(".xn--") || punny) + && (!n.starts_with("xn--") || puny) + // We are a bit stricter, to properly restrict punycode in all labels + && (!n.contains(".xn--") || puny) // Bucket names must not end with "-s3alias" && !n.ends_with("-s3alias") } diff --git a/src/model/helper/locked.rs b/src/model/helper/locked.rs index 16b0bafc..a5821f77 100644 --- a/src/model/helper/locked.rs +++ b/src/model/helper/locked.rs @@ -57,7 +57,7 @@ impl<'a> LockedHelper<'a> { bucket_id: Uuid, alias_name: &String, ) -> Result<(), Error> { - if !is_valid_bucket_name(alias_name, self.0.config.allow_punnycode) { + if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) { return Err(Error::InvalidBucketName(alias_name.to_string())); } @@ -217,7 +217,7 @@ impl<'a> LockedHelper<'a> { ) -> Result<(), Error> { let key_helper = KeyHelper(self.0); - if !is_valid_bucket_name(alias_name, self.0.config.allow_punnycode) { + if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) { return Err(Error::InvalidBucketName(alias_name.to_string())); } diff --git a/src/util/config.rs b/src/util/config.rs index f128177b..c74029e7 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -136,9 +136,9 @@ pub struct Config { #[serde(default = "Default::default")] pub admin: AdminConfig, - /// Allow punnycode in bucket names + /// Allow punycode in bucket names #[serde(default)] - pub allow_punnycode: bool, + pub allow_punycode: bool, } /// Value for data_dir: either a single directory or a list of dirs with attributes From c6bc3f229b5cba9625b240cf60117ba5dc3fba50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arma=C3=ABl=20Gu=C3=A9neau?= Date: Thu, 15 May 2025 23:30:00 +0200 Subject: [PATCH 125/192] Fix behavior of CopyObject wrt x-amz-website-redirect-location --- src/api/s3/copy.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index a5b2d706..edda7e0f 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -29,6 +29,7 @@ use crate::error::*; use crate::get::{full_object_byte_stream, PreconditionHeaders}; use crate::multipart; use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult}; +use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION; use crate::xml::{self as s3_xml, xmlns_tag}; pub const X_AMZ_COPY_SOURCE_IF_MATCH: HeaderName = @@ -84,7 +85,18 @@ pub async fn handle_copy( Some(v) if v == hyper::header::HeaderValue::from_static("REPLACE") => { extract_metadata_headers(req.headers())? } - _ => source_object_meta_inner.into_owned().headers, + _ => { + // The x-amz-website-redirect-location header is not copied, instead + // it is replaced by the value from the request (or removed if no + // value was specified) + let is_redirect = + |(key, _): &(String, String)| key == X_AMZ_WEBSITE_REDIRECT_LOCATION.as_str(); + let mut headers: Vec<_> = source_object_meta_inner.headers.clone(); + headers.retain(|h| !is_redirect(h)); + let new_headers = extract_metadata_headers(req.headers())?; + headers.extend(new_headers.into_iter().filter(is_redirect)); + headers + } }, checksum: source_checksum, }; From 2dc3a6dbbe88a3498a9fc39c50aeb94124c39781 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Thu, 22 May 2025 14:08:06 +0200 Subject: [PATCH 126/192] document allow_punycode configuration option --- doc/book/reference-manual/configuration.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index e0fc17bc..09ce8d24 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -46,6 +46,7 @@ bootstrap_peers = [ "212fd62eeaca72c122b45a7f4fa0f55e012aa5e24ac384a72a3016413fa724ff@[fc00:F::1]:3901", ] +allow_punycode = false [consul_discovery] api = "catalog" @@ -115,6 +116,7 @@ Top-level configuration options: [`rpc_public_addr`](#rpc_public_addr), [`rpc_public_addr_subnet`](#rpc_public_addr_subnet) [`rpc_secret`/`rpc_secret_file`](#rpc_secret). +[`allow_punycode`](#allow_punycode). The `[consul_discovery]` section: [`api`](#consul_api), @@ -604,7 +606,7 @@ be obtained by running `garage node id` and then included directly in the key will be returned by `garage node id` and you will have to add the IP yourself. -### `allow_world_readable_secrets` or `GARAGE_ALLOW_WORLD_READABLE_SECRETS` (env) {#allow_world_readable_secrets} +#### `allow_world_readable_secrets` or `GARAGE_ALLOW_WORLD_READABLE_SECRETS` (env) {#allow_world_readable_secrets} Garage checks the permissions of your secret files to make sure they're not world-readable. In some cases, the check might fail and consider your files as @@ -616,6 +618,13 @@ permission verification. Alternatively, you can set the `GARAGE_ALLOW_WORLD_READABLE_SECRETS` environment variable to `true` to bypass the permissions check. +#### `allow_punycode` {#allow_punycode} + +Allow creating buckets with names containing punycode. When used for buckets served +as websites, this allows using almost any unicode character in the domain name. + +Default to `false`. + ### The `[consul_discovery]` section Garage supports discovering other nodes of the cluster using Consul. For this From ae3f7ee76cf3b45348ba97864313c8f6ddde6e7f Mon Sep 17 00:00:00 2001 From: Renjaya Raga Zenta Date: Tue, 20 May 2025 18:47:50 +0700 Subject: [PATCH 127/192] api: lifecycle: 404 if missing lifecycle config --- src/api/s3/lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/s3/lifecycle.rs b/src/api/s3/lifecycle.rs index c140494e..ccda6cfd 100644 --- a/src/api/s3/lifecycle.rs +++ b/src/api/s3/lifecycle.rs @@ -27,7 +27,7 @@ pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result, Erro .body(string_body(xml))?) } else { Ok(Response::builder() - .status(StatusCode::NO_CONTENT) + .status(StatusCode::NOT_FOUND) .body(empty_body())?) } } From 5e4e870403b990f2c98152707c800d37d05c4c6b Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 18 Feb 2025 12:16:44 +0100 Subject: [PATCH 128/192] add boto3 test for STREAMING-UNSIGNED-PAYLOAD-TRAILER --- flake.lock | 8 ++++---- flake.nix | 4 ++-- script/test-smoke.sh | 13 +++++++++++++ shell.nix | 2 ++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 2cfbfda4..f1b77c6c 100644 --- a/flake.lock +++ b/flake.lock @@ -50,17 +50,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 1736692550, - "narHash": "sha256-7tk8xH+g0sJkKLTJFOxphJxxOjMDFMWv24nXslaU2ro=", + "lastModified": 1747825515, + "narHash": "sha256-BWpMQymVI73QoKZdcVCxUCCK3GNvr/xa2Dc4DM1o2BE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7c4869c47090dd7f9f1bdfb49a22aea026996815", + "rev": "cd2812de55cf87df88a9e09bf3be1ce63d50c1a6", "type": "github" }, "original": { "owner": "NixOS", "repo": "nixpkgs", - "rev": "7c4869c47090dd7f9f1bdfb49a22aea026996815", + "rev": "cd2812de55cf87df88a9e09bf3be1ce63d50c1a6", "type": "github" } }, diff --git a/flake.nix b/flake.nix index fc599e0b..82429a33 100644 --- a/flake.nix +++ b/flake.nix @@ -2,9 +2,9 @@ description = "Garage, an S3-compatible distributed object store for self-hosted deployments"; - # Nixpkgs 24.11 as of 2025-01-12 + # Nixpkgs 25.05 as of 2025-05-22 inputs.nixpkgs.url = - "github:NixOS/nixpkgs/7c4869c47090dd7f9f1bdfb49a22aea026996815"; + "github:NixOS/nixpkgs/cd2812de55cf87df88a9e09bf3be1ce63d50c1a6"; # Rust overlay as of 2025-02-03 inputs.rust-overlay.url = diff --git a/script/test-smoke.sh b/script/test-smoke.sh index acf56a90..cf66e67e 100755 --- a/script/test-smoke.sh +++ b/script/test-smoke.sh @@ -112,6 +112,19 @@ if [ -z "$SKIP_S3CMD" ]; then done fi +# BOTO3 +if [ -z "$SKIP_BOTO3" ]; then + echo "🛠️ Testing with boto3 for STREAMING-UNSIGNED-PAYLOAD-TRAILER" + source ${SCRIPT_FOLDER}/dev-env-aws.sh + AWS_ENDPOINT_URL=https://localhost:4443 python < Date: Sat, 22 Mar 2025 21:03:52 +0100 Subject: [PATCH 129/192] Add Kubernetes CRD and the related kustomization Signed-off-by: babykart --- script/k8s/crd/garagenodes.deuxfleurs.fr.yaml | 43 +++++++++++++++++++ script/k8s/crd/kustomization.yaml | 5 +++ 2 files changed, 48 insertions(+) create mode 100644 script/k8s/crd/garagenodes.deuxfleurs.fr.yaml create mode 100644 script/k8s/crd/kustomization.yaml diff --git a/script/k8s/crd/garagenodes.deuxfleurs.fr.yaml b/script/k8s/crd/garagenodes.deuxfleurs.fr.yaml new file mode 100644 index 00000000..cd0fb166 --- /dev/null +++ b/script/k8s/crd/garagenodes.deuxfleurs.fr.yaml @@ -0,0 +1,43 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: garagenodes.deuxfleurs.fr +spec: + conversion: + strategy: None + group: deuxfleurs.fr + names: + kind: GarageNode + listKind: GarageNodeList + plural: garagenodes + singular: garagenode + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for Node via `CustomResource` + properties: + spec: + properties: + address: + format: ip + type: string + hostname: + type: string + port: + format: uint16 + minimum: 0 + type: integer + required: + - address + - hostname + - port + type: object + required: + - spec + title: GarageNode + type: object + served: true + storage: true + subresources: {} \ No newline at end of file diff --git a/script/k8s/crd/kustomization.yaml b/script/k8s/crd/kustomization.yaml new file mode 100644 index 00000000..9f20eccf --- /dev/null +++ b/script/k8s/crd/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- garagenodes.deuxfleurs.fr.yaml \ No newline at end of file From b15e2cbb6ccc044b868cd8c56306c0b3a34610fa Mon Sep 17 00:00:00 2001 From: babykart Date: Sat, 22 Mar 2025 23:44:55 +0100 Subject: [PATCH 130/192] Update Kubernetes cookbook Signed-off-by: babykart --- doc/book/cookbook/kubernetes.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/book/cookbook/kubernetes.md b/doc/book/cookbook/kubernetes.md index af04e94d..1e7674d7 100644 --- a/doc/book/cookbook/kubernetes.md +++ b/doc/book/cookbook/kubernetes.md @@ -26,6 +26,13 @@ Or deploy with custom values: helm install --create-namespace --namespace garage garage ./garage -f values.override.yaml ``` +If you want to manage the CustomRessourceDefinition used by garage for its `kubernetes_discovery` outside of the helm chart, add `garage.kubernetesSkipCrd: true` to your custom values and use the kustomization before deploying the helm chart: + +```bash +kubectl apply -k ../k8s/crd +helm install --create-namespace --namespace garage garage ./garage -f values.override.yaml +``` + After deploying, cluster layout must be configured manually as described in [Creating a cluster layout](@/documentation/quick-start/_index.md#creating-a-cluster-layout). Use the following command to access garage CLI: ```bash From 2ade8c86f62f0e9eafc2b6515b48f1d45722fb5a Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 18 Mar 2025 11:35:55 +0100 Subject: [PATCH 131/192] more resilience to inconsistent alias states --- src/api/admin/bucket.rs | 2 +- src/api/s3/bucket.rs | 4 +- src/model/helper/locked.rs | 118 +++++++++++++++++++++++++------------ 3 files changed, 84 insertions(+), 40 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index f8bd1eb5..207693b6 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -382,7 +382,7 @@ pub async fn handle_delete_bucket( for ((key_id, alias), _, active) in state.local_aliases.items().iter() { if *active { helper - .unset_local_bucket_alias(bucket.id, key_id, alias) + .purge_local_bucket_alias(bucket.id, key_id, alias) .await?; } } diff --git a/src/api/s3/bucket.rs b/src/api/s3/bucket.rs index 23cceb84..26e2fc49 100644 --- a/src/api/s3/bucket.rs +++ b/src/api/s3/bucket.rs @@ -241,11 +241,11 @@ pub async fn handle_delete_bucket(ctx: ReqCtx) -> Result, Erro // 1. delete bucket alias if is_local_alias { helper - .unset_local_bucket_alias(*bucket_id, &api_key.key_id, bucket_name) + .purge_local_bucket_alias(*bucket_id, &api_key.key_id, bucket_name) .await?; } else { helper - .unset_global_bucket_alias(*bucket_id, bucket_name) + .purge_global_bucket_alias(*bucket_id, bucket_name) .await?; } diff --git a/src/model/helper/locked.rs b/src/model/helper/locked.rs index a5821f77..ecb24854 100644 --- a/src/model/helper/locked.rs +++ b/src/model/helper/locked.rs @@ -47,6 +47,10 @@ impl<'a> LockedHelper<'a> { KeyHelper(self.0) } + // ================================================ + // global bucket aliases + // ================================================ + /// Sets a new alias for a bucket in global namespace. /// This function fails if: /// - alias name is not valid according to S3 spec @@ -179,13 +183,14 @@ impl<'a> LockedHelper<'a> { .ok_or_else(|| Error::NoSuchBucket(alias_name.to_string()))?; // Checks ok, remove alias - let alias_ts = match bucket.state.as_option() { - Some(bucket_state) => increment_logical_clock_2( - alias.state.timestamp(), - bucket_state.aliases.get_timestamp(alias_name), - ), - None => increment_logical_clock(alias.state.timestamp()), - }; + let alias_ts = increment_logical_clock_2( + alias.state.timestamp(), + bucket + .state + .as_option() + .map(|p| p.aliases.get_timestamp(alias_name)) + .unwrap_or(0), + ); // ---- timestamp-ensured causality barrier ---- // writes are now done and all writes use timestamp alias_ts @@ -203,6 +208,10 @@ impl<'a> LockedHelper<'a> { Ok(()) } + // ================================================ + // local bucket aliases + // ================================================ + /// Sets a new alias for a bucket in the local namespace of a key. /// This function fails if: /// - alias name is not valid according to S3 spec @@ -215,14 +224,12 @@ impl<'a> LockedHelper<'a> { key_id: &String, alias_name: &String, ) -> Result<(), Error> { - let key_helper = KeyHelper(self.0); - if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) { return Err(Error::InvalidBucketName(alias_name.to_string())); } let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?; - let mut key = key_helper.get_existing_key(key_id).await?; + let mut key = self.key().get_existing_key(key_id).await?; let key_param = key.state.as_option_mut().unwrap(); @@ -271,23 +278,13 @@ impl<'a> LockedHelper<'a> { key_id: &String, alias_name: &String, ) -> Result<(), Error> { - let key_helper = KeyHelper(self.0); - let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?; - let mut key = key_helper.get_existing_key(key_id).await?; + let mut key = self.key().get_existing_key(key_id).await?; + let key_p = key.state.as_option().unwrap(); let bucket_p = bucket.state.as_option_mut().unwrap(); - if key - .state - .as_option() - .unwrap() - .local_aliases - .get(alias_name) - .cloned() - .flatten() - != Some(bucket_id) - { + if key_p.local_aliases.get(alias_name).cloned().flatten() != Some(bucket_id) { return Err(GarageError::Message(format!( "Bucket {:?} does not have alias {} in namespace of key {}", bucket_id, alias_name, key_id @@ -304,17 +301,17 @@ impl<'a> LockedHelper<'a> { .local_aliases .items() .iter() - .any(|((k, n), _, active)| *k == key.key_id && n == alias_name && *active); + .any(|((k, n), _, active)| (*k != key.key_id || n != alias_name) && *active); + if !has_other_global_aliases && !has_other_local_aliases { return Err(Error::BadRequest(format!("Bucket {} doesn't have other aliases, please delete it instead of just unaliasing.", alias_name))); } // Checks ok, remove alias - let key_param = key.state.as_option_mut().unwrap(); let bucket_p_local_alias_key = (key.key_id.clone(), alias_name.clone()); let alias_ts = increment_logical_clock_2( - key_param.local_aliases.get_timestamp(alias_name), + key_p.local_aliases.get_timestamp(alias_name), bucket_p .local_aliases .get_timestamp(&bucket_p_local_alias_key), @@ -323,7 +320,8 @@ impl<'a> LockedHelper<'a> { // ---- timestamp-ensured causality barrier ---- // writes are now done and all writes use timestamp alias_ts - key_param.local_aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, None); + key.state.as_option_mut().unwrap().local_aliases = + LwwMap::raw_item(alias_name.clone(), alias_ts, None); self.0.key_table.insert(&key).await?; bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false); @@ -332,21 +330,68 @@ impl<'a> LockedHelper<'a> { Ok(()) } + /// Ensures a bucket does not have a certain local alias. + /// Contrarily to unset_local_bucket_alias, this does not + /// fail on any condition other than: + /// - bucket cannot be found (its fine if it is in deleted state) + /// - key cannot be found (its fine if alias in key points to nothing + /// or to another bucket) + pub async fn purge_local_bucket_alias( + &self, + bucket_id: Uuid, + key_id: &String, + alias_name: &String, + ) -> Result<(), Error> { + let mut bucket = self.bucket().get_internal_bucket(bucket_id).await?; + let mut key = self.key().get_internal_key(key_id).await?; + + let bucket_p_local_alias_key = (key.key_id.clone(), alias_name.clone()); + + let alias_ts = increment_logical_clock_2( + key.state + .as_option() + .map(|p| p.local_aliases.get_timestamp(alias_name)) + .unwrap_or(0), + bucket + .state + .as_option() + .map(|p| p.local_aliases.get_timestamp(&bucket_p_local_alias_key)) + .unwrap_or(0), + ); + + // ---- timestamp-ensured causality barrier ---- + // writes are now done and all writes use timestamp alias_ts + + if let Some(kp) = key.state.as_option_mut() { + kp.local_aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, None); + self.0.key_table.insert(&key).await?; + } + + if let Some(bp) = bucket.state.as_option_mut() { + bp.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false); + self.0.bucket_table.insert(&bucket).await?; + } + + Ok(()) + } + + // ================================================ + // permissions + // ================================================ + /// Sets permissions for a key on a bucket. /// This function fails if: /// - bucket or key cannot be found at all (its ok if they are in deleted state) - /// - bucket or key is in deleted state and we are trying to set permissions other than "deny - /// all" + /// - bucket or key is in deleted state and we are trying to set + /// permissions other than "deny all" pub async fn set_bucket_key_permissions( &self, bucket_id: Uuid, key_id: &String, mut perm: BucketKeyPerm, ) -> Result<(), Error> { - let key_helper = KeyHelper(self.0); - let mut bucket = self.bucket().get_internal_bucket(bucket_id).await?; - let mut key = key_helper.get_internal_key(key_id).await?; + let mut key = self.key().get_internal_key(key_id).await?; if let Some(bstate) = bucket.state.as_option() { if let Some(kp) = bstate.authorized_keys.get(key_id) { @@ -383,21 +428,20 @@ impl<'a> LockedHelper<'a> { Ok(()) } - // ---- + // ================================================ + // keys + // ================================================ /// Deletes an API access key pub async fn delete_key(&self, key: &mut Key) -> Result<(), Error> { let state = key.state.as_option_mut().unwrap(); // --- done checking, now commit --- - // (the step at unset_local_bucket_alias will fail if a bucket - // does not have another alias, the deletion will be - // interrupted in the middle if that happens) // 1. Delete local aliases for (alias, _, to) in state.local_aliases.items().iter() { if let Some(bucket_id) = to { - self.unset_local_bucket_alias(*bucket_id, &key.key_id, alias) + self.purge_local_bucket_alias(*bucket_id, &key.key_id, alias) .await?; } } From 8654eb19bf8a59f8ece8ad70ac8096c799858876 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 19 Mar 2025 12:39:32 +0100 Subject: [PATCH 132/192] implement repair procedure to fix inconsistent bucket aliases --- src/garage/cli/structs.rs | 3 + src/garage/repair/online.rs | 4 + src/model/helper/locked.rs | 193 ++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 4ec35e68..3652ef6b 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -478,6 +478,9 @@ pub enum RepairWhat { /// Recalculate block reference counters #[structopt(name = "block-rc", version = garage_version())] BlockRc, + /// Fix inconsistency in bucket aliases (WARNING: EXPERIMENTAL) + #[structopt(name = "aliases", version = garage_version())] + Aliases, /// Verify integrity of all blocks on disc #[structopt(name = "scrub", version = garage_version())] Scrub { diff --git a/src/garage/repair/online.rs b/src/garage/repair/online.rs index 47883f97..950cd5f7 100644 --- a/src/garage/repair/online.rs +++ b/src/garage/repair/online.rs @@ -88,6 +88,10 @@ pub async fn launch_online_repair( garage.block_manager.clone(), )); } + RepairWhat::Aliases => { + info!("Repairing bucket aliases (foreground)"); + garage.locked_helper().await.repair_aliases().await?; + } } Ok(()) } diff --git a/src/model/helper/locked.rs b/src/model/helper/locked.rs index ecb24854..98344b63 100644 --- a/src/model/helper/locked.rs +++ b/src/model/helper/locked.rs @@ -1,3 +1,7 @@ +use std::collections::{HashMap, HashSet}; + +use garage_db as db; + use garage_util::crdt::*; use garage_util::data::*; use garage_util::error::{Error as GarageError, OkOrMessage}; @@ -458,4 +462,193 @@ impl<'a> LockedHelper<'a> { Ok(()) } + + // ================================================ + // repair procedure + // ================================================ + + pub async fn repair_aliases(&self) -> Result<(), GarageError> { + self.0.db.transaction(|tx| { + info!("--- begin repair_aliases transaction ----"); + + // 1. List all non-deleted buckets, so that we can fix bad aliases + let mut all_buckets: HashSet = HashSet::new(); + + for item in tx.range::<&[u8], _>(&self.0.bucket_table.data.store, ..)? { + let bucket = self + .0 + .bucket_table + .data + .decode_entry(&(item?.1)) + .map_err(db::TxError::Abort)?; + if !bucket.is_deleted() { + all_buckets.insert(bucket.id); + } + } + + info!("number of buckets: {}", all_buckets.len()); + + // 2. List all aliases declared in bucket_alias_table and key_table + // Take note of aliases that point to non-existing buckets + let mut global_aliases: HashMap = HashMap::new(); + + { + let mut delete_global = vec![]; + for item in tx.range::<&[u8], _>(&self.0.bucket_alias_table.data.store, ..)? { + let mut alias = self + .0 + .bucket_alias_table + .data + .decode_entry(&(item?.1)) + .map_err(db::TxError::Abort)?; + if let Some(id) = alias.state.get() { + if all_buckets.contains(id) { + // keep aliases + global_aliases.insert(alias.name().to_string(), *id); + } else { + // delete alias + warn!( + "global alias: remove {} -> {:?} (bucket is deleted)", + alias.name(), + id + ); + alias.state.update(None); + delete_global.push(alias); + } + } + } + + info!("number of global aliases: {}", global_aliases.len()); + + info!("global alias table: {} entries fixed", delete_global.len()); + for ga in delete_global { + debug!("Enqueue update to global alias table: {:?}", ga); + self.0.bucket_alias_table.queue_insert(tx, &ga)?; + } + } + + let mut local_aliases: HashMap<(String, String), Uuid> = HashMap::new(); + + { + let mut delete_local = vec![]; + + for item in tx.range::<&[u8], _>(&self.0.key_table.data.store, ..)? { + let mut key = self + .0 + .key_table + .data + .decode_entry(&(item?.1)) + .map_err(db::TxError::Abort)?; + let Some(p) = key.state.as_option_mut() else { + continue; + }; + let mut has_changes = false; + for (name, _, to) in p.local_aliases.items().to_vec() { + if let Some(id) = to { + if all_buckets.contains(&id) { + local_aliases.insert((key.key_id.clone(), name), id); + } else { + warn!( + "local alias: remove ({}, {}) -> {:?} (bucket is deleted)", + key.key_id, name, id + ); + p.local_aliases.update_in_place(name, None); + has_changes = true; + } + } + } + if has_changes { + delete_local.push(key); + } + } + + info!("number of local aliases: {}", local_aliases.len()); + + info!("key table: {} entries fixed", delete_local.len()); + for la in delete_local { + debug!("Enqueue update to key table: {:?}", la); + self.0.key_table.queue_insert(tx, &la)?; + } + } + + // 4. Reverse the alias maps to determine the aliases per-bucket + let mut bucket_global: HashMap> = HashMap::new(); + let mut bucket_local: HashMap> = HashMap::new(); + + for (name, bucket) in global_aliases { + bucket_global.entry(bucket).or_default().push(name); + } + for ((key, name), bucket) in local_aliases { + bucket_local.entry(bucket).or_default().push((key, name)); + } + + // 5. Fix the bucket table to ensure consistency + let mut bucket_updates = vec![]; + + for item in tx.range::<&[u8], _>(&self.0.bucket_table.data.store, ..)? { + let bucket = self + .0 + .bucket_table + .data + .decode_entry(&(item?.1)) + .map_err(db::TxError::Abort)?; + let mut bucket2 = bucket.clone(); + let Some(param) = bucket2.state.as_option_mut() else { + continue; + }; + + // fix global aliases + { + let ga = bucket_global.remove(&bucket.id).unwrap_or_default(); + for (name, _, active) in param.aliases.items().to_vec() { + if active && !ga.contains(&name) { + warn!("bucket {:?}: remove global alias {}", bucket.id, name); + param.aliases.update_in_place(name, false); + } + } + for name in ga { + if param.aliases.get(&name).copied() != Some(true) { + warn!("bucket {:?}: add global alias {}", bucket.id, name); + param.aliases.update_in_place(name, true); + } + } + } + + // fix local aliases + { + let la = bucket_local.remove(&bucket.id).unwrap_or_default(); + for (pair, _, active) in param.local_aliases.items().to_vec() { + if active && !la.contains(&pair) { + warn!("bucket {:?}: remove local alias {:?}", bucket.id, pair); + param.local_aliases.update_in_place(pair, false); + } + } + for pair in la { + if param.local_aliases.get(&pair).copied() != Some(true) { + warn!("bucket {:?}: add local alias {:?}", bucket.id, pair); + param.local_aliases.update_in_place(pair, true); + } + } + } + + if bucket2 != bucket { + bucket_updates.push(bucket2); + } + } + + info!("bucket table: {} entries fixed", bucket_updates.len()); + for b in bucket_updates { + debug!("Enqueue update to bucket table: {:?}", b); + self.0.bucket_table.queue_insert(tx, &b)?; + } + + info!("--- end repair_aliases transaction ----"); + + Ok(()) + })?; + + info!("repair_aliases is done"); + + Ok(()) + } } From 6529ff379ac5737513fe92ba0060d94407ccb58d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 23 May 2025 17:02:23 +0200 Subject: [PATCH 133/192] documentation updates --- doc/book/reference-manual/configuration.md | 14 +++++++------- doc/book/reference-manual/s3-compatibility.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index 09ce8d24..091419d9 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -93,30 +93,30 @@ The following gives details about each available configuration option. [Environment variables](#env_variables). -Top-level configuration options: +Top-level configuration options, in alphabetical order: +[`allow_punycode`](#allow_punycode), [`allow_world_readable_secrets`](#allow_world_readable_secrets), [`block_ram_buffer_max`](#block_ram_buffer_max), [`block_size`](#block_size), [`bootstrap_peers`](#bootstrap_peers), [`compression_level`](#compression_level), +[`consistency_mode`](#consistency_mode), [`data_dir`](#data_dir), [`data_fsync`](#data_fsync), [`db_engine`](#db_engine), [`disable_scrub`](#disable_scrub), -[`use_local_tz`](#use_local_tz), [`lmdb_map_size`](#lmdb_map_size), [`metadata_auto_snapshot_interval`](#metadata_auto_snapshot_interval), [`metadata_dir`](#metadata_dir), [`metadata_fsync`](#metadata_fsync), [`metadata_snapshots_dir`](#metadata_snapshots_dir), [`replication_factor`](#replication_factor), -[`consistency_mode`](#consistency_mode), [`rpc_bind_addr`](#rpc_bind_addr), [`rpc_bind_outgoing`](#rpc_bind_outgoing), [`rpc_public_addr`](#rpc_public_addr), [`rpc_public_addr_subnet`](#rpc_public_addr_subnet) -[`rpc_secret`/`rpc_secret_file`](#rpc_secret). -[`allow_punycode`](#allow_punycode). +[`rpc_secret`/`rpc_secret_file`](#rpc_secret), +[`use_local_tz`](#use_local_tz). The `[consul_discovery]` section: [`api`](#consul_api), @@ -171,7 +171,7 @@ values in the configuration file: ### Top-level configuration options -#### `replication_factor` {#replication_factor} +#### `replication_factor` (since `v1.0.0`) {#replication_factor} The replication factor can be any positive integer smaller or equal the node count in your cluster. The chosen replication factor has a big impact on the cluster's failure tolerancy and performance characteristics. @@ -219,7 +219,7 @@ is in progress. In theory, no data should be lost as rebalancing is a routine operation for Garage, although we cannot guarantee you that everything will go right in such an extreme scenario. -#### `consistency_mode` {#consistency_mode} +#### `consistency_mode` (since `v1.0.0`) {#consistency_mode} The consistency mode setting determines the read and write behaviour of your cluster. diff --git a/doc/book/reference-manual/s3-compatibility.md b/doc/book/reference-manual/s3-compatibility.md index d2c47f3e..edf8de0d 100644 --- a/doc/book/reference-manual/s3-compatibility.md +++ b/doc/book/reference-manual/s3-compatibility.md @@ -23,7 +23,6 @@ Feel free to open a PR to suggest fixes this table. Minio is missing because the - 2022-05-25 - Many Ceph S3 endpoints are not documented but implemented. Following a notification from the Ceph community, we added them. - ## High-level features | Feature | Garage | [Openstack Swift](https://docs.openstack.org/swift/latest/s3_compat.html) | [Ceph Object Gateway](https://docs.ceph.com/en/latest/radosgw/s3/) | [Riak CS](https://docs.riak.com/riak/cs/2.1.1/references/apis/storage/s3/index.html) | [OpenIO](https://docs.openio.io/latest/source/arch-design/s3_compliancy.html) | @@ -34,6 +33,7 @@ Feel free to open a PR to suggest fixes this table. Minio is missing because the | [URL vhost-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access) URL (eg. `bucket.host.tld/key`) | ✅ Implemented | ❌| ✅| ✅ | ✅ | | [Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html) | ✅ Implemented | ❌| ✅ | ✅ | ✅(❓) | | [SSE-C encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html) | ✅ Implemented | ❓ | ✅ | ❌ | ✅ | +| [Bucket versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) | ❌ Missing | ✅ | ✅ | ❌ | ✅ | *Note:* OpenIO does not says if it supports presigned URLs. Because it is part of signature v4 and they claim they support it without additional precisions, From ffbce0f689a05975a5cd68b312bfefbad2dccf2b Mon Sep 17 00:00:00 2001 From: Yureka Date: Mon, 12 May 2025 19:39:20 +0200 Subject: [PATCH 134/192] speed up UploadPartCopy (cherry picked from commit db54bf96c7e35851ffbcf3f93fcefb0b9da72000) --- src/api/s3/copy.rs | 73 +++++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index edda7e0f..969541ad 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -559,6 +559,7 @@ pub async fn handle_upload_part_copy( let mut current_offset = 0; let mut next_block = defragmenter.next().await?; + let mut blocks_to_dup = dest_version.clone(); // TODO this could be optimized similarly to read_and_put_blocks // low priority because uploadpartcopy is rarely used @@ -588,8 +589,7 @@ pub async fn handle_upload_part_copy( .unwrap()?; checksummer = checksummer_updated; - dest_version.blocks.clear(); - dest_version.blocks.put( + let (version_block_key, version_block) = ( VersionBlockKey { part_number, offset: current_offset, @@ -601,37 +601,56 @@ pub async fn handle_upload_part_copy( ); current_offset += data_len; - let block_ref = BlockRef { - block: final_hash, - version: dest_version_id, - deleted: false.into(), + let next = if let Some(final_data) = data_to_upload { + dest_version.blocks.clear(); + dest_version.blocks.put(version_block_key, version_block); + let block_ref = BlockRef { + block: final_hash, + version: dest_version_id, + deleted: false.into(), + }; + let (_, _, _, next) = futures::try_join!( + // Thing 1: if the block is not exactly a block that existed before, + // we need to insert that data as a new block. + garage.block_manager.rpc_put_block( + final_hash, + final_data, + dest_encryption.is_encrypted(), + None + ), + // Thing 2: we need to insert the block in the version + garage.version_table.insert(&dest_version), + // Thing 3: we need to add a block reference + garage.block_ref_table.insert(&block_ref), + // Thing 4: we need to read the next block + defragmenter.next(), + )?; + next + } else { + blocks_to_dup.blocks.put(version_block_key, version_block); + defragmenter.next().await? }; - - let (_, _, _, next) = futures::try_join!( - // Thing 1: if the block is not exactly a block that existed before, - // we need to insert that data as a new block. - async { - if let Some(final_data) = data_to_upload { - garage - .block_manager - .rpc_put_block(final_hash, final_data, dest_encryption.is_encrypted(), None) - .await - } else { - Ok(()) - } - }, - // Thing 2: we need to insert the block in the version - garage.version_table.insert(&dest_version), - // Thing 3: we need to add a block reference - garage.block_ref_table.insert(&block_ref), - // Thing 4: we need to read the next block - defragmenter.next(), - )?; next_block = next; } assert_eq!(current_offset, source_range.length); + // Put the duplicated blocks into the version & block_refs tables + let block_refs_to_put = blocks_to_dup + .blocks + .items() + .iter() + .map(|b| BlockRef { + block: b.1.hash, + version: dest_version_id, + deleted: false.into(), + }) + .collect::>(); + futures::try_join!( + garage.version_table.insert(&blocks_to_dup), + garage.block_ref_table.insert_many(&block_refs_to_put[..]), + )?; + let checksums = checksummer.finalize(); let etag = dest_encryption.etag_from_md5(&checksums.md5); let checksum = checksums.extract(dest_object_checksum_algorithm); From 1b042e379eda36b3b435edfe2ab16a465a9eccaf Mon Sep 17 00:00:00 2001 From: Renjaya Raga Zenta Date: Mon, 19 May 2025 18:28:00 +0700 Subject: [PATCH 135/192] api: s3: implement get bucket acl --- src/api/s3/api_server.rs | 1 + src/api/s3/bucket.rs | 60 ++++++++++++++++++++++++++++++- src/api/s3/xml.rs | 77 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index e26c2b65..337ddb23 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -226,6 +226,7 @@ impl ApiHandler for S3ApiServer { Endpoint::DeleteBucket {} => handle_delete_bucket(ctx).await, Endpoint::GetBucketLocation {} => handle_get_bucket_location(ctx), Endpoint::GetBucketVersioning {} => handle_get_bucket_versioning(), + Endpoint::GetBucketAcl {} => handle_get_bucket_acl(ctx), Endpoint::ListObjects { delimiter, encoding_type, diff --git a/src/api/s3/bucket.rs b/src/api/s3/bucket.rs index 26e2fc49..55caa6c8 100644 --- a/src/api/s3/bucket.rs +++ b/src/api/s3/bucket.rs @@ -5,7 +5,7 @@ use hyper::{Request, Response, StatusCode}; use garage_model::bucket_alias_table::*; use garage_model::bucket_table::Bucket; use garage_model::garage::Garage; -use garage_model::key_table::Key; +use garage_model::key_table::{Key, KeyParams}; use garage_model::permission::BucketKeyPerm; use garage_table::util::*; use garage_util::crdt::*; @@ -44,6 +44,55 @@ pub fn handle_get_bucket_versioning() -> Result, Error> { .body(string_body(xml))?) } +pub fn handle_get_bucket_acl(ctx: ReqCtx) -> Result, Error> { + let ReqCtx { + bucket_id, api_key, .. + } = ctx; + let key_p = api_key.params().ok_or_internal_error( + "Key should not be in deleted state at this point (in handle_get_bucket_acl)", + )?; + + let mut grants: Vec = vec![]; + let kp = api_key.bucket_permissions(&bucket_id); + + if kp.allow_owner { + grants.push(s3_xml::Grant { + grantee: create_grantee(&key_p, &api_key), + permission: s3_xml::Value("FULL_CONTROL".to_string()), + }); + } else { + if kp.allow_read { + grants.push(s3_xml::Grant { + grantee: create_grantee(&key_p, &api_key), + permission: s3_xml::Value("READ".to_string()), + }); + grants.push(s3_xml::Grant { + grantee: create_grantee(&key_p, &api_key), + permission: s3_xml::Value("READ_ACP".to_string()), + }); + } + if kp.allow_write { + grants.push(s3_xml::Grant { + grantee: create_grantee(&key_p, &api_key), + permission: s3_xml::Value("WRITE".to_string()), + }); + } + } + + let access_control_policy = s3_xml::AccessControlPolicy { + xmlns: (), + owner: None, + acl: s3_xml::AccessControlList { entries: grants }, + }; + + let xml = s3_xml::to_xml_with_header(&access_control_policy)?; + trace!("xml: {}", xml); + + Ok(Response::builder() + .header("Content-Type", "application/xml") + .body(string_body(xml))?) +} + pub async fn handle_list_buckets( garage: &Garage, api_key: &Key, @@ -311,6 +360,15 @@ fn parse_create_bucket_xml(xml_bytes: &[u8]) -> Option> { Some(ret) } +fn create_grantee(key_params: &KeyParams, api_key: &Key) -> s3_xml::Grantee { + s3_xml::Grantee { + xmlns_xsi: (), + typ: "CanonicalUser".to_string(), + display_name: Some(s3_xml::Value(key_params.name.get().to_string())), + id: Some(s3_xml::Value(api_key.key_id.to_string())), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index e8af3ec0..fdb36318 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -13,6 +13,10 @@ pub fn xmlns_tag(_v: &(), s: S) -> Result { s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/") } +pub fn xmlns_xsi_tag(_v: &(), s: S) -> Result { + s.serialize_str("http://www.w3.org/2001/XMLSchema-instance") +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Value(#[serde(rename = "$value")] pub String); @@ -319,6 +323,42 @@ pub struct PostObject { pub etag: Value, } +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct Grantee { + #[serde(rename = "xmlns:xsi", serialize_with = "xmlns_xsi_tag")] + pub xmlns_xsi: (), + #[serde(rename = "xsi:type")] + pub typ: String, + #[serde(rename = "DisplayName")] + pub display_name: Option, + #[serde(rename = "ID")] + pub id: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct Grant { + #[serde(rename = "Grantee")] + pub grantee: Grantee, + #[serde(rename = "Permission")] + pub permission: Value, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct AccessControlList { + #[serde(rename = "Grant")] + pub entries: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct AccessControlPolicy { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "Owner")] + pub owner: Option, + #[serde(rename = "AccessControlList")] + pub acl: AccessControlList, +} + #[cfg(test)] mod tests { use super::*; @@ -427,6 +467,43 @@ mod tests { Ok(()) } + #[test] + fn get_bucket_acl_result() -> Result<(), ApiError> { + let grant = Grant { + grantee: Grantee { + xmlns_xsi: (), + typ: "CanonicalUser".to_string(), + display_name: Some(Value("owner_name".to_string())), + id: Some(Value("qsdfjklm".to_string())), + }, + permission: Value("FULL_CONTROL".to_string()), + }; + + let get_bucket_acl = AccessControlPolicy { + xmlns: (), + owner: None, + acl: AccessControlList { + entries: vec![grant], + }, + }; + assert_eq!( + to_xml_with_header(&get_bucket_acl)?, + "\ +\ + \ + \ + \ + owner_name\ + qsdfjklm\ + \ + FULL_CONTROL\ + \ + \ +" + ); + Ok(()) + } + #[test] fn delete_result() -> Result<(), ApiError> { let delete_result = DeleteResult { From 2a4f729b573f5f174ca31f061b0eed80fe48ff90 Mon Sep 17 00:00:00 2001 From: James O'Claire Date: Wed, 28 May 2025 09:49:50 +0800 Subject: [PATCH 136/192] Minor doc change to clarify why the capacity does not matter and how the zone name is used --- doc/book/quick-start/_index.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/book/quick-start/_index.md b/doc/book/quick-start/_index.md index 2db4211b..ff0c1510 100644 --- a/doc/book/quick-start/_index.md +++ b/doc/book/quick-start/_index.md @@ -182,11 +182,12 @@ ID Hostname Address Tag Zone Capacit ## Creating a cluster layout Creating a cluster layout for a Garage deployment means informing Garage -of the disk space available on each node of the cluster -as well as the zone (e.g. datacenter) each machine is located in. +of the disk space available on each node of the cluster, `-c`, +as well as the name of the zone (e.g. datacenter), `-z`, each machine is located in. -For our test deployment, we are using only one node. The way in which we configure -it does not matter, you can simply write: +For our test deployment, we are have only one node with zone named `dc1` and a +capacity of `1G`, though the capacity is ignored for a single node deployment +and can be changed later when adding new nodes. ```bash garage layout assign -z dc1 -c 1G From fc8fc60f6dae85c70a6350fdcfd560f024656c0e Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Fri, 30 May 2025 16:24:12 +0000 Subject: [PATCH 137/192] emit internal error when we detect race condition (#1053) (fix #1050) i went with a `500`/`InternalError`/`Please try again.` because that is something i've seen AWS S3 report while developing other software, and i'm not convinced all clients would understand a 409 conflict properly (GET don't usually conflict) Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1053 Co-authored-by: trinity-1686a Co-committed-by: trinity-1686a --- src/api/s3/copy.rs | 4 +++- src/api/s3/get.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index 969541ad..47a63c82 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -26,7 +26,7 @@ use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; use crate::encryption::EncryptionParams; use crate::error::*; -use crate::get::{full_object_byte_stream, PreconditionHeaders}; +use crate::get::{check_version_not_deleted, full_object_byte_stream, PreconditionHeaders}; use crate::multipart; use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult}; use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION; @@ -237,6 +237,7 @@ async fn handle_copy_metaonly( .get(&source_version.uuid, &EmptyKey) .await?; let source_version = source_version.ok_or(Error::NoSuchKey)?; + check_version_not_deleted(&source_version)?; // Write an "uploading" marker in Object table // This holds a reference to the object in the Version table @@ -428,6 +429,7 @@ pub async fn handle_upload_part_copy( .get(&source_object_version.uuid, &EmptyKey) .await? .ok_or(Error::NoSuchKey)?; + check_version_not_deleted(&source_version)?; // We want to reuse blocks from the source version as much as possible. // However, we still need to get the data from these blocks diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index 22076603..888a040a 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -19,12 +19,13 @@ use garage_net::stream::ByteStream; use garage_rpc::rpc_helper::OrderTag; use garage_table::EmptyKey; use garage_util::data::*; -use garage_util::error::OkOrMessage; +use garage_util::error::{Error as UtilError, OkOrMessage}; use garage_model::garage::Garage; use garage_model::s3::object_table::*; use garage_model::s3::version_table::*; +use garage_api_common::common_error::CommonError; use garage_api_common::helpers::*; use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AMZ_CHECKSUM_MODE}; @@ -215,6 +216,7 @@ pub async fn handle_head_without_ctx( .get(&object_version.uuid, &EmptyKey) .await? .ok_or(Error::NoSuchKey)?; + check_version_not_deleted(&version)?; let (part_offset, part_end) = calculate_part_bounds(&version, pn).ok_or(Error::InvalidPart)?; @@ -365,6 +367,21 @@ pub async fn handle_get_without_ctx( } } +pub(crate) fn check_version_not_deleted(version: &Version) -> Result<(), Error> { + if version.deleted.get() { + // the version was deleted between when the object_table was consulted + // and now, this could mean the object was deleted, or overriden. + // Rather than say the key doesn't exist, return a transient error + // to signal the client to try again. + return Err(CommonError::InternalError(UtilError::Message( + "conflict/inconsistency between object and version state, version is deleted" + .to_string(), + )) + .into()); + } + Ok(()) +} + async fn handle_get_full( garage: Arc, version: &ObjectVersion, @@ -431,6 +448,7 @@ pub fn full_object_byte_stream( .ok_or_message("channel closed")?; let version = version_fut.await.unwrap()?.ok_or(Error::NoSuchKey)?; + check_version_not_deleted(&version)?; for (i, (_, vb)) in version.blocks.items().iter().enumerate().skip(1) { let stream_block_i = encryption .get_block(&garage, &vb.hash, Some(order_stream.order(i as u64))) @@ -446,6 +464,14 @@ pub fn full_object_byte_stream( { Ok(()) => (), Err(e) => { + // TODO i think this is a bad idea, we should log + // an error and stop there. If the error happens to + // be exactly the size of what hasn't been streamed + // yet, the client will see the request as a + // success + // instead truncating the output notify the client + // something happened with their download, so that + // they can retry it let _ = tx.send(error_stream_item(e)).await; } } @@ -497,7 +523,7 @@ async fn handle_get_range( .get(&version.uuid, &EmptyKey) .await? .ok_or(Error::NoSuchKey)?; - + check_version_not_deleted(&version)?; let body = body_from_blocks_range(garage, encryption, version.blocks.items(), begin, end); Ok(resp_builder.body(body)?) @@ -548,6 +574,8 @@ async fn handle_get_part( .await? .ok_or(Error::NoSuchKey)?; + check_version_not_deleted(&version)?; + let (begin, end) = calculate_part_bounds(&version, part_number).ok_or(Error::InvalidPart)?; From 8843aa92fa1cc1edbb0cffdeac4b0be644e619d9 Mon Sep 17 00:00:00 2001 From: Renjaya Raga Zenta Date: Mon, 11 Nov 2024 14:58:09 +0700 Subject: [PATCH 138/192] feat: add log to journald feature The systemd-journald is used in most major Linux distros that use systemd. This enables logging using the systemd-journald native protocol, instead of just writing to stderr. --- Cargo.lock | 12 +++++++ Cargo.toml | 1 + doc/book/reference-manual/configuration.md | 4 +++ nix/compile.nix | 1 + src/garage/Cargo.toml | 3 ++ src/garage/main.rs | 37 ++++++++++++++++++++++ 6 files changed, 58 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e65778cc..8301d8ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1250,6 +1250,7 @@ dependencies = [ "timeago", "tokio", "tracing", + "tracing-journald", "tracing-subscriber", ] @@ -4514,6 +4515,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tracing-journald" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0b4143302cf1022dac868d521e36e8b27691f72c84b3311750d5188ebba657" +dependencies = [ + "libc", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 400c1840..b57f890c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ pretty_env_logger = "0.5" structopt = { version = "0.3", default-features = false } syslog-tracing = "0.3" tracing = "0.1" +tracing-journald = "0.3.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } heed = { version = "0.11", default-features = false, features = ["lmdb"] } diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index 091419d9..32dc461b 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -160,6 +160,10 @@ variable, it does not exist in the configuration file: Garage daemon send its logs to `syslog` (using the libc `syslog` function) instead of printing to stderr. +- `GARAGE_LOG_TO_JOURNALD` (since `v2.0.0`): set this to `1` or `true` to make the + Garage daemon send its logs to `journald` (using the native protocol of `systemd-journald`) + instead of printing to stderr. + The following environment variables can be used to override the corresponding values in the configuration file: diff --git a/nix/compile.nix b/nix/compile.nix index 8cd88d01..bbadaa37 100644 --- a/nix/compile.nix +++ b/nix/compile.nix @@ -74,6 +74,7 @@ let "metrics" "telemetry-otlp" "syslog" + "journald" ])); featuresStr = lib.concatStringsSep "," rootFeatures; diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index f03c7331..d2785f06 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -57,6 +57,7 @@ opentelemetry.workspace = true opentelemetry-prometheus = { workspace = true, optional = true } opentelemetry-otlp = { workspace = true, optional = true } syslog-tracing = { workspace = true, optional = true } +tracing-journald = { workspace = true, optional = true } [dev-dependencies] garage_api_common.workspace = true @@ -101,6 +102,8 @@ metrics = [ "garage_api_admin/metrics", "opentelemetry-prometheus" ] telemetry-otlp = [ "opentelemetry-otlp" ] # Logging to syslog syslog = [ "syslog-tracing" ] +# Logging to journald +journald = [ "tracing-journald" ] # NOTE: bundled-libs and system-libs should be treat as mutually exclusive; # exactly one of them should be enabled. diff --git a/src/garage/main.rs b/src/garage/main.rs index ac95e854..2703bedd 100644 --- a/src/garage/main.rs +++ b/src/garage/main.rs @@ -208,6 +208,43 @@ fn init_logging(opt: &Opt) { } } + if std::env::var("GARAGE_LOG_TO_JOURNALD") + .map(|x| x == "1" || x == "true") + .unwrap_or(false) + { + #[cfg(feature = "journald")] + { + use tracing_journald::{Priority, PriorityMappings}; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + let registry = tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer().with_writer(std::io::sink)) + .with(env_filter); + match tracing_journald::layer() { + Ok(layer) => { + registry + .with(layer.with_priority_mappings(PriorityMappings { + info: Priority::Informational, + debug: Priority::Debug, + ..PriorityMappings::new() + })) + .init(); + } + Err(e) => { + eprintln!("Couldn't connect to journald: {}.", e); + std::process::exit(1); + } + } + return; + } + #[cfg(not(feature = "journald"))] + { + eprintln!("Journald support is not enabled in this build."); + std::process::exit(1); + } + } + tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter(env_filter) From 47143b88ad648635d085893977d750ef3a211087 Mon Sep 17 00:00:00 2001 From: eddster2309 Date: Tue, 3 Jun 2025 09:15:57 +0000 Subject: [PATCH 139/192] Add eddster2309/ansible-role-garage as deployment option --- doc/book/cookbook/ansible.md | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/doc/book/cookbook/ansible.md b/doc/book/cookbook/ansible.md index 6d624c9c..2d0a4a83 100644 --- a/doc/book/cookbook/ansible.md +++ b/doc/book/cookbook/ansible.md @@ -8,18 +8,18 @@ have published Ansible roles. We list them and compare them below. ## Comparison of Ansible roles -| Feature | [ansible-role-garage](#zorun-ansible-role-garage) | [garage-docker-ansible-deploy](#moan0s-garage-docker-ansible-deploy) | -|------------------------------------|---------------------------------------------|---------------------------------------------------------------| -| **Runtime** | Systemd | Docker | -| **Target OS** | Any Linux | Any Linux | -| **Architecture** | amd64, arm64, i686 | amd64, arm64 | -| **Additional software** | None | Traefik | -| **Automatic node connection** | ❌ | ✅ | -| **Layout management** | ❌ | ✅ | -| **Manage buckets & keys** | ❌ | ✅ (basic) | -| **Allow custom Garage config** | ✅ | ❌ | -| **Facilitate Garage upgrades** | ✅ | ❌ | -| **Multiple instances on one host** | ✅ | ✅ | +| Feature | [ansible-role-garage](#zorun-ansible-role-garage) | [garage-docker-ansible-deploy](#moan0s-garage-docker-ansible-deploy) | [eddster ansible-role-garage](#eddster-ansible-role-garage) | +|------------------------------------|---------------------------------------------|---------------------------------------------------------------|---------------------------------| +| **Runtime** | Systemd | Docker | Systemd | +| **Target OS** | Any Linux | Any Linux | Any Linux | +| **Architecture** | amd64, arm64, i686 | amd64, arm64 | amd64 | +| **Additional software** | None | Traefik | Ngnix and Keepalived (optional) | +| **Automatic node connection** | ❌ | ✅ | ✅ | +| **Layout management** | ❌ | ✅ | ✅ | +| **Manage buckets & keys** | ❌ | ✅ (basic) | ✅ | +| **Allow custom Garage config** | ✅ | ❌ | ❌ | +| **Facilitate Garage upgrades** | ✅ | ❌ | ✅ | +| **Multiple instances on one host** | ✅ | ✅ | ❌ | ## zorun/ansible-role-garage @@ -49,3 +49,15 @@ structured DNS names, etc). As a result, this role makes it easier to start with Garage on Ansible, but is less flexible. + +## eddster2309/ansible-role-garage + +[Source code](https://github.com/eddster2309/ansible-role-garage), [Ansible galaxy](https://galaxy.ansible.com/ui/standalone/roles/eddster2309/garage/) + +This role is a opinionated but customisable role using the official Garage +static binaries and only requires Systemd. As such it should work on any +Linux based host. It includes all the nesscary configuration to +automatically setup a clustered Garage deployment. Most Garage +configuration options are exposed through Ansible variables so while you +can't provide a custom config you can get very close. It can optionally +installed a HA nginx deployment with Keepalived. From adfa44ad70b53614b863de7e94d78f54bb4e10c9 Mon Sep 17 00:00:00 2001 From: eddster2309 Date: Tue, 3 Jun 2025 09:22:43 +0000 Subject: [PATCH 140/192] Add architecture support --- doc/book/cookbook/ansible.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/book/cookbook/ansible.md b/doc/book/cookbook/ansible.md index 2d0a4a83..8b0d2969 100644 --- a/doc/book/cookbook/ansible.md +++ b/doc/book/cookbook/ansible.md @@ -12,7 +12,7 @@ have published Ansible roles. We list them and compare them below. |------------------------------------|---------------------------------------------|---------------------------------------------------------------|---------------------------------| | **Runtime** | Systemd | Docker | Systemd | | **Target OS** | Any Linux | Any Linux | Any Linux | -| **Architecture** | amd64, arm64, i686 | amd64, arm64 | amd64 | +| **Architecture** | amd64, arm64, i686 | amd64, arm64 | arm64, arm, 386, amd64 | | **Additional software** | None | Traefik | Ngnix and Keepalived (optional) | | **Automatic node connection** | ❌ | ✅ | ✅ | | **Layout management** | ❌ | ✅ | ✅ | From 26bc8079050a52be9a12d02fc49fe20645660c78 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Tue, 10 Jun 2025 20:42:59 +0200 Subject: [PATCH 141/192] put web error in a basic webpage before, it was a plain string, with an xml content type this caused browsers to show very ugly and meaningless pages --- src/garage/tests/s3/website.rs | 42 ++++++++++++++++++++++++++++++++++ src/web/web_server.rs | 24 +++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/garage/tests/s3/website.rs b/src/garage/tests/s3/website.rs index 6d37eee8..bbac3de5 100644 --- a/src/garage/tests/s3/website.rs +++ b/src/garage/tests/s3/website.rs @@ -606,3 +606,45 @@ async fn test_website_puny() { ); } } + +#[tokio::test] +async fn test_website_object_not_found() { + const BCKT_NAME: &str = "not-found"; + let ctx = common::context(); + let _bucket = ctx.create_bucket(BCKT_NAME); + + let client = Client::builder(TokioExecutor::new()).build_http(); + + let req = |suffix| { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/", ctx.garage.web_port)) + .header("Host", format!("{}{}", BCKT_NAME, suffix)) + .body(Body::new(Bytes::new())) + .unwrap() + }; + + ctx.garage + .command() + .args(["bucket", "website", "--allow", BCKT_NAME]) + .quiet() + .expect_success_status("Could not allow website on bucket"); + + let resp = client.request(req("")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + // the error we return by default are *not* xml + assert_eq!( + resp.headers().get(http::header::CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + let result = String::from_utf8( + resp.into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + assert!(result.contains("not found")); +} diff --git a/src/web/web_server.rs b/src/web/web_server.rs index 242f7801..ea02ab0f 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -397,10 +397,30 @@ fn error_to_res(e: Error) -> Response> { // was a HEAD request or we couldn't get the error document) // We do NOT enter this code path when returning the bucket's // error document (this is handled in serve_file) - let body = string_body(format!("{}\n", e)); - let mut http_error = Response::new(body); + let mut body_str = format!( + r"{http_code} {code_text} +

{http_code} {code_text}

", + http_code = e.http_status_code().as_u16(), + code_text = e.http_status_code().canonical_reason().unwrap_or("Unknown"), + ); + if let Error::ApiError(ref err) = e { + body_str.push_str(&format!( + r" +
    +
  • Code: {s3_code}
  • +
  • Message: {s3_message}.
  • +
", + s3_code = err.aws_code(), + s3_message = err, + )); + } + let mut http_error = Response::new(string_body(body_str)); *http_error.status_mut() = e.http_status_code(); e.add_headers(http_error.headers_mut()); + http_error.headers_mut().insert( + http::header::CONTENT_TYPE, + "text/html; charset=utf-8".parse().unwrap(), + ); http_error } From 85ee4f5d8c10e676e7c9ed4da3734f449ce1ac7c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 13 Jun 2025 13:49:35 +0200 Subject: [PATCH 142/192] cli: mark block refs as deleted in garage block purge --- src/garage/admin/block.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/garage/admin/block.rs b/src/garage/admin/block.rs index edeb88c0..5f908ce4 100644 --- a/src/garage/admin/block.rs +++ b/src/garage/admin/block.rs @@ -101,6 +101,7 @@ impl AdminRpcHandler { let mut obj_dels = 0; let mut mpu_dels = 0; let mut ver_dels = 0; + let mut br_dels = 0; for hash in blocks { let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?; @@ -131,12 +132,19 @@ impl AdminRpcHandler { ver_dels += 1; } } + if !br.deleted.get() { + let mut br = br; + br.deleted.set(); + self.garage.block_ref_table.insert(&br).await?; + br_dels += 1; + } } } Ok(AdminRpc::Ok(format!( - "Purged {} blocks, {} versions, {} objects, {} multipart uploads", + "Purged {} blocks: marked {} block refs, {} versions, {} objects and {} multipart uploads as deleted", blocks.len(), + br_dels, ver_dels, obj_dels, mpu_dels, From fbf03e93784b76c2efb82ff8f62e1e7d32869ec9 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 13 Jun 2025 14:21:28 +0200 Subject: [PATCH 143/192] bump version to v1.2.0 --- Cargo.lock | 26 +++++++++++----------- Cargo.toml | 24 ++++++++++---------- doc/book/cookbook/real-world.md | 10 ++++----- doc/book/quick-start/_index.md | 2 +- doc/book/reference-manual/configuration.md | 6 ++--- doc/drafts/admin-api.md | 2 +- script/helm/garage/Chart.yaml | 6 ++--- script/helm/garage/README.md | 2 +- src/api/admin/Cargo.toml | 2 +- src/api/common/Cargo.toml | 2 +- src/api/k2v/Cargo.toml | 2 +- src/api/s3/Cargo.toml | 2 +- src/block/Cargo.toml | 2 +- src/db/Cargo.toml | 2 +- src/garage/Cargo.toml | 2 +- src/model/Cargo.toml | 2 +- src/net/Cargo.toml | 2 +- src/rpc/Cargo.toml | 2 +- src/table/Cargo.toml | 2 +- src/util/Cargo.toml | 2 +- src/web/Cargo.toml | 2 +- 21 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8301d8ee..5180fb29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1200,7 +1200,7 @@ dependencies = [ [[package]] name = "garage" -version = "1.1.0" +version = "1.2.0" dependencies = [ "assert-json-diff", "async-trait", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "garage_api_admin" -version = "1.1.0" +version = "1.2.0" dependencies = [ "argon2", "async-trait", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "garage_api_common" -version = "1.1.0" +version = "1.2.0" dependencies = [ "base64 0.21.7", "bytes", @@ -1316,7 +1316,7 @@ dependencies = [ [[package]] name = "garage_api_k2v" -version = "1.1.0" +version = "1.2.0" dependencies = [ "base64 0.21.7", "err-derive", @@ -1339,7 +1339,7 @@ dependencies = [ [[package]] name = "garage_api_s3" -version = "1.1.0" +version = "1.2.0" dependencies = [ "aes-gcm", "async-compression", @@ -1384,7 +1384,7 @@ dependencies = [ [[package]] name = "garage_block" -version = "1.1.0" +version = "1.2.0" dependencies = [ "arc-swap", "async-compression", @@ -1409,7 +1409,7 @@ dependencies = [ [[package]] name = "garage_db" -version = "1.1.0" +version = "1.2.0" dependencies = [ "err-derive", "heed", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "garage_model" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "base64 0.21.7", @@ -1449,7 +1449,7 @@ dependencies = [ [[package]] name = "garage_net" -version = "1.1.0" +version = "1.2.0" dependencies = [ "arc-swap", "bytes", @@ -1474,7 +1474,7 @@ dependencies = [ [[package]] name = "garage_rpc" -version = "1.1.0" +version = "1.2.0" dependencies = [ "arc-swap", "async-trait", @@ -1506,7 +1506,7 @@ dependencies = [ [[package]] name = "garage_table" -version = "1.1.0" +version = "1.2.0" dependencies = [ "arc-swap", "async-trait", @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "garage_util" -version = "1.1.0" +version = "1.2.0" dependencies = [ "arc-swap", "async-trait", @@ -1559,7 +1559,7 @@ dependencies = [ [[package]] name = "garage_web" -version = "1.1.0" +version = "1.2.0" dependencies = [ "err-derive", "garage_api_common", diff --git a/Cargo.toml b/Cargo.toml index b57f890c..789225b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,18 +24,18 @@ default-members = ["src/garage"] # Internal Garage crates format_table = { version = "0.1.1", path = "src/format-table" } -garage_api_common = { version = "1.1.0", path = "src/api/common" } -garage_api_admin = { version = "1.1.0", path = "src/api/admin" } -garage_api_s3 = { version = "1.1.0", path = "src/api/s3" } -garage_api_k2v = { version = "1.1.0", path = "src/api/k2v" } -garage_block = { version = "1.1.0", path = "src/block" } -garage_db = { version = "1.1.0", path = "src/db", default-features = false } -garage_model = { version = "1.1.0", path = "src/model", default-features = false } -garage_net = { version = "1.1.0", path = "src/net" } -garage_rpc = { version = "1.1.0", path = "src/rpc" } -garage_table = { version = "1.1.0", path = "src/table" } -garage_util = { version = "1.1.0", path = "src/util" } -garage_web = { version = "1.1.0", path = "src/web" } +garage_api_common = { version = "1.2.0", path = "src/api/common" } +garage_api_admin = { version = "1.2.0", path = "src/api/admin" } +garage_api_s3 = { version = "1.2.0", path = "src/api/s3" } +garage_api_k2v = { version = "1.2.0", path = "src/api/k2v" } +garage_block = { version = "1.2.0", path = "src/block" } +garage_db = { version = "1.2.0", path = "src/db", default-features = false } +garage_model = { version = "1.2.0", path = "src/model", default-features = false } +garage_net = { version = "1.2.0", path = "src/net" } +garage_rpc = { version = "1.2.0", path = "src/rpc" } +garage_table = { version = "1.2.0", path = "src/table" } +garage_util = { version = "1.2.0", path = "src/util" } +garage_web = { version = "1.2.0", path = "src/web" } k2v-client = { version = "0.0.4", path = "src/k2v-client" } # External crates from crates.io diff --git a/doc/book/cookbook/real-world.md b/doc/book/cookbook/real-world.md index 594f1905..998c02a5 100644 --- a/doc/book/cookbook/real-world.md +++ b/doc/book/cookbook/real-world.md @@ -96,14 +96,14 @@ to store 2 TB of data in total. ## Get a Docker image Our docker image is currently named `dxflrs/garage` and is stored on the [Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated). -We encourage you to use a fixed tag (eg. `v1.1.0`) and not the `latest` tag. -For this example, we will use the latest published version at the time of the writing which is `v1.1.0` but it's up to you +We encourage you to use a fixed tag (eg. `v1.2.0`) and not the `latest` tag. +For this example, we will use the latest published version at the time of the writing which is `v1.2.0` but it's up to you to check [the most recent versions on the Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated). For example: ``` -sudo docker pull dxflrs/garage:v1.1.0 +sudo docker pull dxflrs/garage:v1.2.0 ``` ## Deploying and configuring Garage @@ -171,7 +171,7 @@ docker run \ -v /etc/garage.toml:/etc/garage.toml \ -v /var/lib/garage/meta:/var/lib/garage/meta \ -v /var/lib/garage/data:/var/lib/garage/data \ - dxflrs/garage:v1.1.0 + dxflrs/garage:v1.2.0 ``` With this command line, Garage should be started automatically at each boot. @@ -185,7 +185,7 @@ If you want to use `docker-compose`, you may use the following `docker-compose.y version: "3" services: garage: - image: dxflrs/garage:v1.1.0 + image: dxflrs/garage:v1.2.0 network_mode: "host" restart: unless-stopped volumes: diff --git a/doc/book/quick-start/_index.md b/doc/book/quick-start/_index.md index ff0c1510..45a4a43b 100644 --- a/doc/book/quick-start/_index.md +++ b/doc/book/quick-start/_index.md @@ -132,7 +132,7 @@ docker run \ -v /path/to/garage.toml:/etc/garage.toml \ -v /path/to/garage/meta:/var/lib/garage/meta \ -v /path/to/garage/data:/var/lib/garage/data \ - dxflrs/garage:v1.1.0 + dxflrs/garage:v1.2.0 ``` Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903` diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index 32dc461b..84aaf511 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -153,14 +153,14 @@ The `[admin]` section: ### Environment variables {#env_variables} -The following configuration parameter must be specified as an environment -variable, it does not exist in the configuration file: +The following configuration parameters must be specified as environment variables, +they do not exist in the configuration file: - `GARAGE_LOG_TO_SYSLOG` (since `v0.9.4`): set this to `1` or `true` to make the Garage daemon send its logs to `syslog` (using the libc `syslog` function) instead of printing to stderr. -- `GARAGE_LOG_TO_JOURNALD` (since `v2.0.0`): set this to `1` or `true` to make the +- `GARAGE_LOG_TO_JOURNALD` (since `v1.2.0`): set this to `1` or `true` to make the Garage daemon send its logs to `journald` (using the native protocol of `systemd-journald`) instead of printing to stderr. diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index acceefab..a3d03c41 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -70,7 +70,7 @@ Example response body: ```json { "node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df", - "garageVersion": "v1.1.0", + "garageVersion": "v1.2.0", "garageFeatures": [ "k2v", "lmdb", diff --git a/script/helm/garage/Chart.yaml b/script/helm/garage/Chart.yaml index 7a89409e..6806e593 100644 --- a/script/helm/garage/Chart.yaml +++ b/script/helm/garage/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: garage description: S3-compatible object store for small self-hosted geo-distributed deployments type: application -version: 0.7.0 -appVersion: "v1.1.0" +version: 0.7.1 +appVersion: "v1.2.0" home: https://garagehq.deuxfleurs.fr/ icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg @@ -15,4 +15,4 @@ keywords: sources: - https://git.deuxfleurs.fr/Deuxfleurs/garage.git -maintainers: [] \ No newline at end of file +maintainers: [] diff --git a/script/helm/garage/README.md b/script/helm/garage/README.md index fcf988ca..05d444a3 100644 --- a/script/helm/garage/README.md +++ b/script/helm/garage/README.md @@ -1,6 +1,6 @@ # garage -![Version: 0.7.0](https://img.shields.io/badge/Version-0.7.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.1.0](https://img.shields.io/badge/AppVersion-v1.1.0-informational?style=flat-square) +![Version: 0.7.1](https://img.shields.io/badge/Version-0.7.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.2.0](https://img.shields.io/badge/AppVersion-v1.2.0-informational?style=flat-square) S3-compatible object store for small self-hosted geo-distributed deployments diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 7b1d65e1..6b039eeb 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_admin" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index b1a8b47a..a67e9d9c 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_common" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/k2v/Cargo.toml b/src/api/k2v/Cargo.toml index 385aef3b..845d23f6 100644 --- a/src/api/k2v/Cargo.toml +++ b/src/api/k2v/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_k2v" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 7b0cac94..1ba7565d 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_s3" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/block/Cargo.toml b/src/block/Cargo.toml index 1f5558c5..d5f8e58e 100644 --- a/src/block/Cargo.toml +++ b/src/block/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_block" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index bfc9029c..666296ce 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_db" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index d2785f06..ae3b5609 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 42ec8537..376eaa9a 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_model" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/net/Cargo.toml b/src/net/Cargo.toml index b48eb153..17a0eb24 100644 --- a/src/net/Cargo.toml +++ b/src/net/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_net" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/rpc/Cargo.toml b/src/rpc/Cargo.toml index e6466001..a314271f 100644 --- a/src/rpc/Cargo.toml +++ b/src/rpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_rpc" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/table/Cargo.toml b/src/table/Cargo.toml index ef7b44e4..c76c5b78 100644 --- a/src/table/Cargo.toml +++ b/src/table/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_table" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/util/Cargo.toml b/src/util/Cargo.toml index 123406db..f59e44e2 100644 --- a/src/util/Cargo.toml +++ b/src/util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_util" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index c4fdbc0e..5d208e6e 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_web" -version = "1.1.0" +version = "1.2.0" authors = ["Alex Auvolat ", "Quentin Dufour "] edition = "2018" license = "AGPL-3.0" From 3a4afc04a9f157ecf09bac2417f9b043da783b1f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 13 Jun 2025 17:22:47 +0200 Subject: [PATCH 144/192] cargo: update crossbeam-channel to avoid yanked version --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5180fb29..6acd85ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,9 +867,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] From 1b42919bf7d63a7f9856e926c2068af5bbbb6d39 Mon Sep 17 00:00:00 2001 From: Arthur Carcano Date: Wed, 9 Jul 2025 12:32:56 +0200 Subject: [PATCH 145/192] Fix some unsoundness in lmdb adapter unsafe --- src/db/lmdb_adapter.rs | 66 +++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/db/lmdb_adapter.rs b/src/db/lmdb_adapter.rs index 259aa566..bd85f1b4 100644 --- a/src/db/lmdb_adapter.rs +++ b/src/db/lmdb_adapter.rs @@ -1,8 +1,8 @@ use core::ops::Bound; -use core::ptr::NonNull; use std::collections::HashMap; use std::convert::TryInto; +use std::marker::PhantomPinned; use std::path::PathBuf; use std::pin::Pin; use std::sync::{Arc, RwLock}; @@ -159,13 +159,15 @@ impl IDb for LmdbDb { fn iter(&self, tree: usize) -> Result> { let tree = self.get_tree(tree)?; let tx = self.db.read_txn()?; - TxAndIterator::make(tx, |tx| Ok(tree.iter(tx)?)) + // Safety: the cloture does not store its argument anywhere, + unsafe { TxAndIterator::make(tx, |tx| Ok(tree.iter(tx)?)) } } fn iter_rev(&self, tree: usize) -> Result> { let tree = self.get_tree(tree)?; let tx = self.db.read_txn()?; - TxAndIterator::make(tx, |tx| Ok(tree.rev_iter(tx)?)) + // Safety: the cloture does not store its argument anywhere, + unsafe { TxAndIterator::make(tx, |tx| Ok(tree.rev_iter(tx)?)) } } fn range<'r>( @@ -176,7 +178,8 @@ impl IDb for LmdbDb { ) -> Result> { let tree = self.get_tree(tree)?; let tx = self.db.read_txn()?; - TxAndIterator::make(tx, |tx| Ok(tree.range(tx, &(low, high))?)) + // Safety: the cloture does not store its argument anywhere, + unsafe { TxAndIterator::make(tx, |tx| Ok(tree.range(tx, &(low, high))?)) } } fn range_rev<'r>( &self, @@ -186,7 +189,8 @@ impl IDb for LmdbDb { ) -> Result> { let tree = self.get_tree(tree)?; let tx = self.db.read_txn()?; - TxAndIterator::make(tx, |tx| Ok(tree.rev_range(tx, &(low, high))?)) + // Safety: the cloture does not store its argument anywhere, + unsafe { TxAndIterator::make(tx, |tx| Ok(tree.rev_range(tx, &(low, high))?)) } } // ---- @@ -316,28 +320,41 @@ where { tx: RoTxn<'a>, iter: Option, + _pin: PhantomPinned, } impl<'a, I> TxAndIterator<'a, I> where I: Iterator> + 'a, { - fn make(tx: RoTxn<'a>, iterfun: F) -> Result> + fn iter(self: Pin<&mut Self>) -> &mut Option { + // Safety: iter is not structural + unsafe { &mut self.get_unchecked_mut().iter } + } + + /// Safety: iterfun must not store its argument anywhere but in its result. + unsafe fn make(tx: RoTxn<'a>, iterfun: F) -> Result> where F: FnOnce(&'a RoTxn<'a>) -> Result, { - let res = TxAndIterator { tx, iter: None }; + let res = TxAndIterator { + tx, + iter: None, + _pin: PhantomPinned, + }; let mut boxed = Box::pin(res); - // This unsafe allows us to bypass lifetime checks - let tx = unsafe { NonNull::from(&boxed.tx).as_ref() }; - let iter = iterfun(tx)?; + let tx_lifetime_overextended: &'a RoTxn<'a> = { + let tx = &boxed.tx; + // Safety: Artificially extending the lifetime because + // this reference will only be stored and accessed from the + // returned ValueIter which guarantees that it is destroyed + // before the tx it is pointing to. + unsafe { &*&raw const *tx } + }; + let iter = iterfun(&tx_lifetime_overextended)?; - let mut_ref = Pin::as_mut(&mut boxed); - // This unsafe allows us to write in a field of the pinned struct - unsafe { - Pin::get_unchecked_mut(mut_ref).iter = Some(iter); - } + *boxed.as_mut().iter() = Some(iter); Ok(Box::new(TxAndIteratorPin(boxed))) } @@ -348,8 +365,10 @@ where I: Iterator> + 'a, { fn drop(&mut self) { - // ensure the iterator is dropped before the RoTxn it references - drop(self.iter.take()); + // Safety: `new_unchecked` is okay because we know this value is never + // used again after being dropped. + let this = unsafe { Pin::new_unchecked(self) }; + drop(this.iter().take()); } } @@ -365,13 +384,12 @@ where fn next(&mut self) -> Option { let mut_ref = Pin::as_mut(&mut self.0); - // This unsafe allows us to mutably access the iterator field - let next = unsafe { Pin::get_unchecked_mut(mut_ref).iter.as_mut()?.next() }; - match next { - None => None, - Some(Err(e)) => Some(Err(e.into())), - Some(Ok((k, v))) => Some(Ok((k.to_vec(), v.to_vec()))), - } + let next = mut_ref.iter().as_mut()?.next()?; + let res = match next { + Err(e) => Err(e.into()), + Ok((k, v)) => Ok((k.to_vec(), v.to_vec())), + }; + Some(res) } } From 70cf6004ae79c26f0d1b17b03fb92b5081b83efb Mon Sep 17 00:00:00 2001 From: Lapineige Date: Fri, 1 Aug 2025 21:32:59 +0000 Subject: [PATCH 146/192] Fix typo in peertube buckets names --- doc/book/connect/apps/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/book/connect/apps/index.md b/doc/book/connect/apps/index.md index baf6ba50..f4ca9865 100644 --- a/doc/book/connect/apps/index.md +++ b/doc/book/connect/apps/index.md @@ -144,10 +144,10 @@ garage key new --name peertube-key Keep the Key ID and the Secret key in a pad, they will be needed later. -We need two buckets, one for normal videos (named peertube-video) and one for webtorrent videos (named peertube-playlist). +We need two buckets, one for normal videos (named peertube-videos) and one for webtorrent videos (named peertube-playlists). ```bash garage bucket create peertube-videos -garage bucket create peertube-playlist +garage bucket create peertube-playlists ``` Now we allow our key to read and write on these buckets: @@ -206,7 +206,7 @@ object_storage: proxify_private_files: false streaming_playlists: - bucket_name: 'peertube-playlist' + bucket_name: 'peertube-playlists' # Keep it empty for our example prefix: '' From cc29a40d51222d9dffb36e0747d4a164d1d0f9b8 Mon Sep 17 00:00:00 2001 From: Lapineige Date: Fri, 1 Aug 2025 21:35:15 +0000 Subject: [PATCH 147/192] Actualiser doc/book/connect/apps/index.md --- doc/book/connect/apps/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/book/connect/apps/index.md b/doc/book/connect/apps/index.md index 242e6fb1..f52d434b 100644 --- a/doc/book/connect/apps/index.md +++ b/doc/book/connect/apps/index.md @@ -12,7 +12,7 @@ In this section, we cover the following web applications: | [Mastodon](#mastodon) | ✅ | Natively supported | | [Matrix](#matrix) | ✅ | Tested with `synapse-s3-storage-provider` | | [ejabberd](#ejabberd) | ✅ | `mod_s3_upload` | -| [Pixelfed](#pixelfed) | ❓ | Not yet tested | +| [Pixelfed](#pixelfed) | ✅ | Natively supported | | [Pleroma](#pleroma) | ❓ | Not yet tested | | [Lemmy](#lemmy) | ✅ | Supported with pict-rs | | [Funkwhale](#funkwhale) | ❓ | Not yet tested | From f930c6f64302d2de1cff6fab6ed95468d2d99969 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 2 Aug 2025 13:09:33 +0200 Subject: [PATCH 148/192] don't die on SIGHUP --- src/garage/server.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/garage/server.rs b/src/garage/server.rs index 1dc86fd3..b81ae334 100644 --- a/src/garage/server.rs +++ b/src/garage/server.rs @@ -183,10 +183,21 @@ fn watch_shutdown_signal() -> watch::Receiver { let mut sigterm = signal(SignalKind::terminate()).expect("Failed to install SIGTERM handler"); let mut sighup = signal(SignalKind::hangup()).expect("Failed to install SIGHUP handler"); - tokio::select! { - _ = sigint.recv() => info!("Received SIGINT, shutting down."), - _ = sigterm.recv() => info!("Received SIGTERM, shutting down."), - _ = sighup.recv() => info!("Received SIGHUP, shutting down."), + loop { + tokio::select! { + _ = sigint.recv() => { + info!("Received SIGINT, shutting down."); + break + } + _ = sigterm.recv() => { + info!("Received SIGTERM, shutting down."); + break + } + _ = sighup.recv() => { + info!("Received SIGHUP, reload not supported."); + continue + } + } } send_cancel.send(true).unwrap(); }); From 5469c9587718b24eb4b58ed9a5cbe39dfe39777b Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 2 Aug 2025 12:51:37 +0200 Subject: [PATCH 149/192] handle ECONNABORTED --- src/api/common/generic_server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/common/generic_server.rs b/src/api/common/generic_server.rs index 6ddc2ff2..8f9bcdfb 100644 --- a/src/api/common/generic_server.rs +++ b/src/api/common/generic_server.rs @@ -343,7 +343,11 @@ where while !*must_exit.borrow() { let (stream, client_addr) = tokio::select! { - acc = listener.accept() => acc?, + acc = listener.accept() => match acc { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::ConnectionAborted => continue, + Err(e) => return Err(e.into()), + }, _ = must_exit.changed() => continue, }; From b340599e6865ecd488c7a88487c48b410e45d9f8 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 2 Aug 2025 13:43:38 +0200 Subject: [PATCH 150/192] log access keys --- src/api/common/generic_server.rs | 28 ++++++++++++++++++---------- src/api/common/signature/payload.rs | 4 ++-- src/api/k2v/api_server.rs | 6 ++++++ src/api/s3/api_server.rs | 6 ++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/api/common/generic_server.rs b/src/api/common/generic_server.rs index 6ddc2ff2..8453dc07 100644 --- a/src/api/common/generic_server.rs +++ b/src/api/common/generic_server.rs @@ -33,6 +33,7 @@ use garage_util::metrics::{gen_trace_id, RecordDuration}; use garage_util::socket_address::UnixOrTCPSocketAddress; use crate::helpers::{BoxBody, ErrorBody}; +use crate::signature::payload::Authorization; pub trait ApiEndpoint: Send + Sync + 'static { fn name(&self) -> &'static str; @@ -58,6 +59,12 @@ pub trait ApiHandler: Send + Sync + 'static { req: Request, endpoint: Self::Endpoint, ) -> impl Future>, Self::Error>> + Send; + + /// Returns the key id used to authenticate this request. The ID returned must be safe to + /// log. + fn key_id_from_request(&self, req: &Request) -> Option { + None + } } pub struct ApiServer { @@ -142,19 +149,20 @@ impl ApiServer { ) -> Result>, http::Error> { let uri = req.uri().clone(); - if let Ok(forwarded_for_ip_addr) = + let source = if let Ok(forwarded_for_ip_addr) = forwarded_headers::handle_forwarded_for_headers(req.headers()) { - info!( - "{} (via {}) {} {}", - forwarded_for_ip_addr, - addr, - req.method(), - uri - ); + format!("{forwarded_for_ip_addr} (via {addr})") } else { - info!("{} {} {}", addr, req.method(), uri); - } + format!("{addr}") + }; + // we only do this to log the access key, so we can discard any error + let key = self + .api_handler + .key_id_from_request(&req) + .map(|k| format!("(key {k}) ")) + .unwrap_or_default(); + info!("{source} {key}{} {uri}", req.method()); debug!("{:?}", req); let tracer = opentelemetry::global::tracer("garage"); diff --git a/src/api/common/signature/payload.rs b/src/api/common/signature/payload.rs index 2d5f8603..c3a7f231 100644 --- a/src/api/common/signature/payload.rs +++ b/src/api/common/signature/payload.rs @@ -417,7 +417,7 @@ pub async fn verify_v4( // ============ Authorization header, or X-Amz-* query params ========= pub struct Authorization { - key_id: String, + pub key_id: String, scope: String, signed_headers: String, signature: String, @@ -426,7 +426,7 @@ pub struct Authorization { } impl Authorization { - fn parse_header(headers: &HeaderMap) -> Result { + pub fn parse_header(headers: &HeaderMap) -> Result { let authorization = headers .get(AUTHORIZATION) .ok_or_bad_request("Missing authorization header")? diff --git a/src/api/k2v/api_server.rs b/src/api/k2v/api_server.rs index de5775da..8e10d9a6 100644 --- a/src/api/k2v/api_server.rs +++ b/src/api/k2v/api_server.rs @@ -176,6 +176,12 @@ impl ApiHandler for K2VApiServer { Ok(resp_ok) } + + fn key_id_from_request(&self, req: &Request) -> Option { + garage_api_common::signature::payload::Authorization::parse_header(req.headers()) + .map(|auth| auth.key_id) + .ok() + } } impl ApiEndpoint for K2VApiEndpoint { diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index 337ddb23..acb0cf56 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -343,6 +343,12 @@ impl ApiHandler for S3ApiServer { Ok(resp_ok) } + + fn key_id_from_request(&self, req: &Request) -> Option { + garage_api_common::signature::payload::Authorization::parse_header(req.headers()) + .map(|auth| auth.key_id) + .ok() + } } impl ApiEndpoint for S3ApiEndpoint { From 96d7713915861da178c101700c37d0ac580dd1dc Mon Sep 17 00:00:00 2001 From: Julien Kritter Date: Fri, 13 Sep 2024 10:40:46 +0200 Subject: [PATCH 151/192] Add support for an LSM-tree-based backend with Fjall --- Cargo.lock | 197 ++++++++++++++++++++- Cargo.toml | 1 + src/db/Cargo.toml | 2 + src/db/fjall_adapter.rs | 366 ++++++++++++++++++++++++++++++++++++++++ src/db/lib.rs | 2 + src/db/open.rs | 23 +++ src/garage/Cargo.toml | 1 + src/model/Cargo.toml | 1 + src/model/garage.rs | 7 + src/util/config.rs | 4 + 10 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 src/db/fjall_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 6acd85ff..cd44160c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" +[[package]] +name = "byteview" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6236364b88b9b6d0bc181ba374cf1ab55ba3ef97a1cb6f8cddad48a273767fb5" + [[package]] name = "cc" version = "1.2.16" @@ -798,6 +804,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "compare" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" + [[package]] name = "core-foundation" version = "0.9.4" @@ -874,6 +886,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -883,6 +904,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -954,6 +985,20 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.10", +] + [[package]] name = "deranged" version = "0.4.0" @@ -996,6 +1041,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "double-ended-peekable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" + [[package]] name = "dyn-clone" version = "1.0.19" @@ -1017,6 +1068,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -1084,6 +1147,23 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fjall" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b25ad44cd4360a0448a9b5a0a6f1c7a621101cca4578706d43c9a821418aebc" +dependencies = [ + "byteorder", + "byteview", + "dashmap 6.1.0", + "log", + "lsm-tree", + "path-absolutize", + "std-semaphore", + "tempfile", + "xxhash-rust", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1412,6 +1492,7 @@ name = "garage_db" version = "1.2.0" dependencies = [ "err-derive", + "fjall", "heed", "mktemp", "r2d2", @@ -1655,6 +1736,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + [[package]] name = "h2" version = "0.3.26" @@ -2229,6 +2316,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interval-heap" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" +dependencies = [ + "compare", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2585,6 +2681,36 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "lsm-tree" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab73c02eadb3dc12c0024e5b61d6284e6d59064e67e74fbad77856caa56f62c7" +dependencies = [ + "byteorder", + "crossbeam-skiplist", + "double-ended-peekable", + "enum_dispatch", + "guardian", + "interval-heap", + "log", + "lz4_flex", + "path-absolutize", + "quick_cache", + "rustc-hash", + "self_cell", + "tempfile", + "value-log", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "lz4_flex" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" + [[package]] name = "matchers" version = "0.1.0" @@ -2839,7 +2965,7 @@ checksum = "6105e89802af13fdf48c49d7646d3b533a70e536d818aae7e78ba0433d01acb8" dependencies = [ "async-trait", "crossbeam-channel", - "dashmap", + "dashmap 4.0.2", "fnv", "futures-channel", "futures-executor", @@ -3000,6 +3126,24 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + [[package]] name = "pem" version = "3.0.5" @@ -3295,6 +3439,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad6644cb07b7f3488b9f3d2fde3b4c0a7fa367cafefb39dff93a659f76eb786" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -3532,6 +3686,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3774,6 +3934,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" + [[package]] name = "semver" version = "1.0.26" @@ -3987,6 +4153,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "std-semaphore" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ae9eec00137a8eed469fb4148acd9fc6ac8c3f9b110f52cd34698c8b5bfa0e" + [[package]] name = "strsim" version = "0.11.1" @@ -4664,6 +4836,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-log" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fc7c4ce161f049607ecea654dca3f2d727da5371ae85e2e4f14ce2b98ed67c" +dependencies = [ + "byteorder", + "byteview", + "interval-heap", + "log", + "path-absolutize", + "rustc-hash", + "tempfile", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "varint-rs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 789225b8..9876db60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ heed = { version = "0.11", default-features = false, features = ["lmdb"] } rusqlite = "0.31.0" r2d2 = "0.8" r2d2_sqlite = "0.24" +fjall = "2.4" async-compression = { version = "0.4", features = ["tokio", "zstd"] } zstd = { version = "0.13", default-features = false } diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index 666296ce..06b2fabc 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -19,6 +19,7 @@ heed = { workspace = true, optional = true } rusqlite = { workspace = true, optional = true, features = ["backup"] } r2d2 = { workspace = true, optional = true } r2d2_sqlite = { workspace = true, optional = true } +fjall = { workspace = true, optional = true } [dev-dependencies] mktemp.workspace = true @@ -27,4 +28,5 @@ mktemp.workspace = true default = [ "lmdb", "sqlite" ] bundled-libs = [ "rusqlite?/bundled" ] lmdb = [ "heed" ] +fjall = [ "dep:fjall" ] sqlite = [ "rusqlite", "r2d2", "r2d2_sqlite" ] diff --git a/src/db/fjall_adapter.rs b/src/db/fjall_adapter.rs new file mode 100644 index 00000000..57b540c1 --- /dev/null +++ b/src/db/fjall_adapter.rs @@ -0,0 +1,366 @@ +use core::ops::Bound; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use fjall::{ + PartitionCreateOptions, PersistMode, TransactionalKeyspace, TransactionalPartitionHandle, + WriteTransaction, +}; + +use crate::{ + Db, Error, IDb, ITx, ITxFn, OnCommit, Result, TxError, TxFnResult, TxOpError, TxOpResult, + TxResult, TxValueIter, Value, ValueIter, +}; + +pub use fjall; + +// -- err + +impl From for Error { + fn from(e: fjall::Error) -> Error { + Error(format!("fjall: {}", e).into()) + } +} + +impl From for Error { + fn from(e: fjall::LsmError) -> Error { + Error(format!("fjall lsm_tree: {}", e).into()) + } +} + +impl From for TxOpError { + fn from(e: fjall::Error) -> TxOpError { + TxOpError(e.into()) + } +} + +// -- db + +pub struct FjallDb { + path: PathBuf, + keyspace: TransactionalKeyspace, + trees: RwLock<(Vec, HashMap)>, +} + +type ByteRefRangeBound<'r> = (Bound<&'r [u8]>, Bound<&'r [u8]>); + +impl FjallDb { + pub fn init(path: &PathBuf, keyspace: TransactionalKeyspace) -> Db { + let s = Self { + path: path.clone(), + keyspace, + trees: RwLock::new((Vec::new(), HashMap::new())), + }; + Db(Arc::new(s)) + } + + fn get_tree(&self, i: usize) -> Result { + self.trees + .read() + .unwrap() + .0 + .get(i) + .cloned() + .ok_or_else(|| Error("invalid tree id".into())) + } + + fn canonicalize(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect::() + } +} + +impl IDb for FjallDb { + fn engine(&self) -> String { + "LSM trees (using Fjall crate)".into() + } + + fn open_tree(&self, name: &str) -> Result { + let mut trees = self.trees.write().unwrap(); + let canonical_name = FjallDb::canonicalize(name); + if let Some(i) = trees.1.get(&canonical_name) { + Ok(*i) + } else { + let tree = self + .keyspace + .open_partition(&canonical_name, PartitionCreateOptions::default())?; + let i = trees.0.len(); + trees.0.push(tree); + trees.1.insert(canonical_name, i); + Ok(i) + } + } + + fn list_trees(&self) -> Result> { + Ok(self + .keyspace + .list_partitions() + .iter() + .map(|n| n.to_string()) + .collect()) + } + + fn snapshot(&self, to: &PathBuf) -> Result<()> { + std::fs::create_dir_all(to)?; + let mut path = to.clone(); + path.push("data.fjall"); + + let source_keyspace = fjall::Config::new(&self.path).open()?; + let copy_keyspace = fjall::Config::new(path).open()?; + + for partition_name in source_keyspace.list_partitions() { + let source_partition = source_keyspace + .open_partition(&partition_name, PartitionCreateOptions::default())?; + let snapshot = source_partition.snapshot(); + let copy_partition = + copy_keyspace.open_partition(&partition_name, PartitionCreateOptions::default())?; + + for entry in snapshot.iter() { + let (key, value) = entry?; + copy_partition.insert(key, value)?; + } + } + + copy_keyspace.persist(PersistMode::SyncAll)?; + Ok(()) + } + + // ---- + + fn get(&self, tree_idx: usize, key: &[u8]) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + let val = tx.get(&tree, key)?; + match val { + None => Ok(None), + Some(v) => Ok(Some(v.to_vec())), + } + } + + fn len(&self, tree_idx: usize) -> Result { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(tx.len(&tree)?) + } + + fn insert(&self, tree_idx: usize, key: &[u8], value: &[u8]) -> Result<()> { + let tree = self.get_tree(tree_idx)?; + let mut tx = self.keyspace.write_tx(); + tx.insert(&tree, key, value); + tx.commit()?; + Ok(()) + } + + fn remove(&self, tree_idx: usize, key: &[u8]) -> Result<()> { + let tree = self.get_tree(tree_idx)?; + let mut tx = self.keyspace.write_tx(); + tx.remove(&tree, key); + tx.commit()?; + Ok(()) + } + + fn clear(&self, tree_idx: usize) -> Result<()> { + let tree = self.get_tree(tree_idx)?; + let tree_name = tree.inner().name.clone(); + self.keyspace.delete_partition(tree)?; + let tree = self + .keyspace + .open_partition(&tree_name, PartitionCreateOptions::default())?; + let mut trees = self.trees.write().unwrap(); + trees.0[tree_idx] = tree; + Ok(()) + } + + fn iter(&self, tree_idx: usize) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new(tx.iter(&tree).map(iterator_remap))) + } + + fn iter_rev(&self, tree_idx: usize) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new(tx.iter(&tree).rev().map(iterator_remap))) + } + + fn range<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new( + tx.range::<&'r [u8], ByteRefRangeBound>(&tree, (low, high)) + .map(iterator_remap), + )) + } + fn range_rev<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new( + tx.range::<&'r [u8], ByteRefRangeBound>(&tree, (low, high)) + .rev() + .map(iterator_remap), + )) + } + + // ---- + + fn transaction(&self, f: &dyn ITxFn) -> TxResult { + let trees = self.trees.read().unwrap(); + let mut tx = FjallTx { + trees: &trees.0[..], + tx: self.keyspace.write_tx(), + }; + + let res = f.try_on(&mut tx); + match res { + TxFnResult::Ok(on_commit) => { + tx.tx.commit().map_err(Error::from).map_err(TxError::Db)?; + Ok(on_commit) + } + TxFnResult::Abort => { + tx.tx.rollback(); + Err(TxError::Abort(())) + } + TxFnResult::DbErr => { + tx.tx.rollback(); + Err(TxError::Db(Error( + "(this message will be discarded)".into(), + ))) + } + } + } +} + +// ---- + +struct FjallTx<'a> { + trees: &'a [TransactionalPartitionHandle], + tx: WriteTransaction<'a>, +} + +impl<'a> FjallTx<'a> { + fn get_tree(&self, i: usize) -> TxOpResult<&TransactionalPartitionHandle> { + self.trees.get(i).ok_or_else(|| { + TxOpError(Error( + "invalid tree id (it might have been openned after the transaction started)".into(), + )) + }) + } +} + +impl<'a> ITx for FjallTx<'a> { + fn get(&self, tree_idx: usize, key: &[u8]) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?; + match self.tx.get(tree, key)? { + Some(v) => Ok(Some(v.to_vec())), + None => Ok(None), + } + } + fn len(&self, tree_idx: usize) -> TxOpResult { + let tree = self.get_tree(tree_idx)?; + Ok(self.tx.len(tree)? as usize) + } + + fn insert(&mut self, tree_idx: usize, key: &[u8], value: &[u8]) -> TxOpResult<()> { + let tree = self.get_tree(tree_idx)?.clone(); + self.tx.insert(&tree, key, value); + Ok(()) + } + fn remove(&mut self, tree_idx: usize, key: &[u8]) -> TxOpResult<()> { + let tree = self.get_tree(tree_idx)?.clone(); + self.tx.remove(&tree, key); + Ok(()) + } + fn clear(&mut self, _tree_idx: usize) -> TxOpResult<()> { + unimplemented!("LSM tree clearing in cross-partition transaction is not supported") + } + + fn iter(&self, tree_idx: usize) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?.clone(); + Ok(Box::new(self.tx.iter(&tree).map(iterator_remap_tx))) + } + fn iter_rev(&self, tree_idx: usize) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?.clone(); + Ok(Box::new(self.tx.iter(&tree).rev().map(iterator_remap_tx))) + } + + fn range<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?; + let low = clone_bound(low); + let high = clone_bound(high); + Ok(Box::new( + self.tx + .range::, ByteVecRangeBounds>(&tree, (low, high)) + .map(iterator_remap_tx), + )) + } + fn range_rev<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?; + let low = clone_bound(low); + let high = clone_bound(high); + Ok(Box::new( + self.tx + .range::, ByteVecRangeBounds>(&tree, (low, high)) + .rev() + .map(iterator_remap_tx), + )) + } +} + +// -- maps fjall's (k, v) to ours + +fn iterator_remap(r: fjall::Result<(fjall::Slice, fjall::Slice)>) -> Result<(Value, Value)> { + r.map(|(k, v)| (k.to_vec(), v.to_vec())) + .map_err(|e| e.into()) +} + +fn iterator_remap_tx(r: fjall::Result<(fjall::Slice, fjall::Slice)>) -> TxOpResult<(Value, Value)> { + r.map(|(k, v)| (k.to_vec(), v.to_vec())) + .map_err(|e| e.into()) +} + +// -- utils to deal with Garage's tightness on Bound lifetimes + +type ByteVecBound = Bound>; +type ByteVecRangeBounds = (ByteVecBound, ByteVecBound); + +fn clone_bound(bound: Bound<&[u8]>) -> ByteVecBound { + let value = match bound { + Bound::Excluded(v) | Bound::Included(v) => v.to_vec(), + Bound::Unbounded => vec![], + }; + + match bound { + Bound::Included(_) => Bound::Included(value), + Bound::Excluded(_) => Bound::Excluded(value), + Bound::Unbounded => Bound::Unbounded, + } +} diff --git a/src/db/lib.rs b/src/db/lib.rs index c55c8643..3454c759 100644 --- a/src/db/lib.rs +++ b/src/db/lib.rs @@ -1,6 +1,8 @@ #[macro_use] extern crate tracing; +#[cfg(feature = "fjall")] +pub mod fjall_adapter; #[cfg(feature = "lmdb")] pub mod lmdb_adapter; #[cfg(feature = "sqlite")] diff --git a/src/db/open.rs b/src/db/open.rs index ff3bc830..83ae1f93 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -1,4 +1,6 @@ +use std::convert::TryInto; use std::path::PathBuf; +use std::sync::Arc; use crate::{Db, Error, Result}; @@ -11,6 +13,7 @@ use crate::{Db, Error, Result}; pub enum Engine { Lmdb, Sqlite, + Fjall, } impl Engine { @@ -19,6 +22,7 @@ impl Engine { match self { Self::Lmdb => "lmdb", Self::Sqlite => "sqlite", + Self::Fjall => "fjall", } } } @@ -36,6 +40,7 @@ impl std::str::FromStr for Engine { match text { "lmdb" | "heed" => Ok(Self::Lmdb), "sqlite" | "sqlite3" | "rusqlite" => Ok(Self::Sqlite), + "fjall" => Ok(Self::Fjall), "sled" => Err(Error("Sled is no longer supported as a database engine. Converting your old metadata db can be done using an older Garage binary (e.g. v0.9.4).".into())), kind => Err(Error( format!( @@ -51,6 +56,7 @@ impl std::str::FromStr for Engine { pub struct OpenOpt { pub fsync: bool, pub lmdb_map_size: Option, + pub fjall_block_cache_size: Option, } impl Default for OpenOpt { @@ -58,6 +64,7 @@ impl Default for OpenOpt { Self { fsync: false, lmdb_map_size: None, + fjall_block_cache_size: None, } } } @@ -114,6 +121,22 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { } } + // ---- Fjall DB ---- + #[cfg(feature = "fjall")] + Engine::Fjall => { + info!("Opening Fjall database at: {}", path.display()); + let fsync_ms = opt.fsync.then(|| 1000 as u16); + let mut config = fjall::Config::new(path).fsync_ms(fsync_ms); + if let Some(block_cache_size) = opt.fjall_block_cache_size { + let block_cache = Arc::new(fjall::BlockCache::with_capacity_bytes( + block_cache_size.try_into().unwrap(), + )); + config = config.block_cache(block_cache); + } + let keyspace = config.open_transactional()?; + Ok(crate::fjall_adapter::FjallDb::init(path, keyspace)) + } + // Pattern is unreachable when all supported DB engines are compiled into binary. The allow // attribute is added so that we won't have to change this match in case stop building // support for one or more engines by default. diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index ae3b5609..7d60313e 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -91,6 +91,7 @@ k2v = [ "garage_util/k2v", "garage_api_k2v" ] # Database engines lmdb = [ "garage_model/lmdb" ] sqlite = [ "garage_model/sqlite" ] +fjall = [ "garage_model/fjall" ] # Automatic registration and discovery via Consul API consul-discovery = [ "garage_rpc/consul-discovery" ] diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 376eaa9a..14f92253 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -44,3 +44,4 @@ default = [ "lmdb", "sqlite" ] k2v = [ "garage_util/k2v" ] lmdb = [ "garage_db/lmdb" ] sqlite = [ "garage_db/sqlite" ] +fjall = [ "garage_db/fjall" ] \ No newline at end of file diff --git a/src/model/garage.rs b/src/model/garage.rs index 11c0d90f..7420e740 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -124,6 +124,9 @@ impl Garage { db::Engine::Lmdb => { db_path.push("db.lmdb"); } + db::Engine::Fjall => { + db_path.push("db.fjall"); + } } let db_opt = db::OpenOpt { fsync: config.metadata_fsync, @@ -131,6 +134,10 @@ impl Garage { v if v == usize::default() => None, v => Some(v), }, + fjall_block_cache_size: match config.fjall_block_cache_size { + v if v == usize::default() => None, + v => Some(v), + }, }; let db = db::open_db(&db_path, db_engine, &db_opt) .ok_or_message("Unable to open metadata db")?; diff --git a/src/util/config.rs b/src/util/config.rs index c74029e7..19c3e821 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -122,6 +122,10 @@ pub struct Config { #[serde(deserialize_with = "deserialize_capacity", default)] pub lmdb_map_size: usize, + /// Fjall block cache size + #[serde(deserialize_with = "deserialize_capacity", default)] + pub fjall_block_cache_size: usize, + // -- APIs /// Configuration for S3 api pub s3_api: S3ApiConfig, From a6c6c44310973aba4625abba3819eaf1099362b5 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 17:56:09 +0100 Subject: [PATCH 152/192] nix: build and test fjall feature --- .woodpecker/debug.yaml | 5 +++++ flake.nix | 3 +++ nix/compile.nix | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.woodpecker/debug.yaml b/.woodpecker/debug.yaml index 65dab9ab..62266aa4 100644 --- a/.woodpecker/debug.yaml +++ b/.woodpecker/debug.yaml @@ -28,6 +28,11 @@ steps: commands: - nix-build -j4 --attr flakePackages.tests-sqlite + - name: unit + func tests (fjall) + image: nixpkgs/nix:nixos-22.05 + commands: + - nix-build -j4 --attr flakePackages.tests-fjall + - name: integration tests image: nixpkgs/nix:nixos-22.05 commands: diff --git a/flake.nix b/flake.nix index fc599e0b..2fb8c48e 100644 --- a/flake.nix +++ b/flake.nix @@ -53,6 +53,9 @@ tests-sqlite = testWith { GARAGE_TEST_INTEGRATION_DB_ENGINE = "sqlite"; }; + tests-fjall = testWith { + GARAGE_TEST_INTEGRATION_DB_ENGINE = "fjall"; + }; }; # ---- developpment shell, for making native builds only ---- diff --git a/nix/compile.nix b/nix/compile.nix index bbadaa37..7e9f79ab 100644 --- a/nix/compile.nix +++ b/nix/compile.nix @@ -68,7 +68,7 @@ let rootFeatures = if features != null then features else - ([ "bundled-libs" "lmdb" "sqlite" "k2v" ] ++ (lib.optionals release [ + ([ "bundled-libs" "lmdb" "sqlite" "fjall" "k2v" ] ++ (lib.optionals release [ "consul-discovery" "kubernetes-discovery" "metrics" From aa69c06f2b1b76630ae5b0f9d14c4223dbee6641 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 27 Aug 2025 19:41:06 +0200 Subject: [PATCH 153/192] fix potential race condition and naming bug in fjall adapter --- Cargo.lock | 1 + Cargo.toml | 1 + src/db/Cargo.toml | 5 +- src/db/fjall_adapter.rs | 170 ++++++++++++++++++++++++++++------------ src/db/open.rs | 8 +- src/db/test.rs | 14 +++- 6 files changed, 139 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd44160c..997d6a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,6 +1495,7 @@ dependencies = [ "fjall", "heed", "mktemp", + "parking_lot 0.12.3", "r2d2", "r2d2_sqlite", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 9876db60..fdec5010 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ md-5 = "0.10" mktemp = "0.5" nix = { version = "0.29", default-features = false, features = ["fs"] } nom = "7.1" +parking_lot = "0.12" parse_duration = "2.1" pin-project = "1.0.12" pnet_datalink = "0.34" diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index 06b2fabc..e9ed15c9 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -16,10 +16,13 @@ err-derive.workspace = true tracing.workspace = true heed = { workspace = true, optional = true } + rusqlite = { workspace = true, optional = true, features = ["backup"] } r2d2 = { workspace = true, optional = true } r2d2_sqlite = { workspace = true, optional = true } + fjall = { workspace = true, optional = true } +parking_lot = { workspace = true, optional = true } [dev-dependencies] mktemp.workspace = true @@ -28,5 +31,5 @@ mktemp.workspace = true default = [ "lmdb", "sqlite" ] bundled-libs = [ "rusqlite?/bundled" ] lmdb = [ "heed" ] -fjall = [ "dep:fjall" ] +fjall = [ "dep:fjall", "dep:parking_lot" ] sqlite = [ "rusqlite", "r2d2", "r2d2_sqlite" ] diff --git a/src/db/fjall_adapter.rs b/src/db/fjall_adapter.rs index 57b540c1..d91ef12f 100644 --- a/src/db/fjall_adapter.rs +++ b/src/db/fjall_adapter.rs @@ -1,8 +1,9 @@ use core::ops::Bound; -use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; + +use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard}; use fjall::{ PartitionCreateOptions, PersistMode, TransactionalKeyspace, TransactionalPartitionHandle, @@ -39,63 +40,48 @@ impl From for TxOpError { // -- db pub struct FjallDb { - path: PathBuf, keyspace: TransactionalKeyspace, - trees: RwLock<(Vec, HashMap)>, + trees: RwLock>, } type ByteRefRangeBound<'r> = (Bound<&'r [u8]>, Bound<&'r [u8]>); impl FjallDb { - pub fn init(path: &PathBuf, keyspace: TransactionalKeyspace) -> Db { + pub fn init(keyspace: TransactionalKeyspace) -> Db { let s = Self { - path: path.clone(), keyspace, - trees: RwLock::new((Vec::new(), HashMap::new())), + trees: RwLock::new(Vec::new()), }; Db(Arc::new(s)) } - fn get_tree(&self, i: usize) -> Result { - self.trees - .read() - .unwrap() - .0 - .get(i) - .cloned() - .ok_or_else(|| Error("invalid tree id".into())) - } - - fn canonicalize(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect::() + fn get_tree( + &self, + i: usize, + ) -> Result> { + RwLockReadGuard::try_map(self.trees.read(), |trees: &Vec<_>| { + trees.get(i).map(|tup| &tup.1) + }) + .map_err(|_| Error("invalid tree id".into())) } } impl IDb for FjallDb { fn engine(&self) -> String { - "LSM trees (using Fjall crate)".into() + "Fjall (EXPERIMENTAL!)".into() } fn open_tree(&self, name: &str) -> Result { - let mut trees = self.trees.write().unwrap(); - let canonical_name = FjallDb::canonicalize(name); - if let Some(i) = trees.1.get(&canonical_name) { - Ok(*i) + let mut trees = self.trees.write(); + let safe_name = encode_name(name)?; + if let Some(i) = trees.iter().position(|(name, _)| *name == safe_name) { + Ok(i) } else { let tree = self .keyspace - .open_partition(&canonical_name, PartitionCreateOptions::default())?; - let i = trees.0.len(); - trees.0.push(tree); - trees.1.insert(canonical_name, i); + .open_partition(&safe_name, PartitionCreateOptions::default())?; + let i = trees.len(); + trees.push((safe_name, tree)); Ok(i) } } @@ -105,8 +91,8 @@ impl IDb for FjallDb { .keyspace .list_partitions() .iter() - .map(|n| n.to_string()) - .collect()) + .map(|n| decode_name(&n)) + .collect::>>()?) } fn snapshot(&self, to: &PathBuf) -> Result<()> { @@ -114,17 +100,17 @@ impl IDb for FjallDb { let mut path = to.clone(); path.push("data.fjall"); - let source_keyspace = fjall::Config::new(&self.path).open()?; + let source_state = self.keyspace.read_tx(); let copy_keyspace = fjall::Config::new(path).open()?; - for partition_name in source_keyspace.list_partitions() { - let source_partition = source_keyspace + for partition_name in self.keyspace.list_partitions() { + let source_partition = self + .keyspace .open_partition(&partition_name, PartitionCreateOptions::default())?; - let snapshot = source_partition.snapshot(); let copy_partition = copy_keyspace.open_partition(&partition_name, PartitionCreateOptions::default())?; - for entry in snapshot.iter() { + for entry in source_state.iter(&source_partition) { let (key, value) = entry?; copy_partition.insert(key, value)?; } @@ -169,14 +155,19 @@ impl IDb for FjallDb { } fn clear(&self, tree_idx: usize) -> Result<()> { - let tree = self.get_tree(tree_idx)?; - let tree_name = tree.inner().name.clone(); + let mut trees = self.trees.write(); + + if tree_idx >= trees.len() { + return Err(Error("invalid tree id".into())); + } + let (name, tree) = trees.remove(tree_idx); + self.keyspace.delete_partition(tree)?; let tree = self .keyspace - .open_partition(&tree_name, PartitionCreateOptions::default())?; - let mut trees = self.trees.write().unwrap(); - trees.0[tree_idx] = tree; + .open_partition(&name, PartitionCreateOptions::default())?; + trees.insert(tree_idx, (name, tree)); + Ok(()) } @@ -223,9 +214,9 @@ impl IDb for FjallDb { // ---- fn transaction(&self, f: &dyn ITxFn) -> TxResult { - let trees = self.trees.read().unwrap(); + let trees = self.trees.read(); let mut tx = FjallTx { - trees: &trees.0[..], + trees: &trees[..], tx: self.keyspace.write_tx(), }; @@ -252,13 +243,13 @@ impl IDb for FjallDb { // ---- struct FjallTx<'a> { - trees: &'a [TransactionalPartitionHandle], + trees: &'a [(String, TransactionalPartitionHandle)], tx: WriteTransaction<'a>, } impl<'a> FjallTx<'a> { fn get_tree(&self, i: usize) -> TxOpResult<&TransactionalPartitionHandle> { - self.trees.get(i).ok_or_else(|| { + self.trees.get(i).map(|tup| &tup.1).ok_or_else(|| { TxOpError(Error( "invalid tree id (it might have been openned after the transaction started)".into(), )) @@ -364,3 +355,78 @@ fn clone_bound(bound: Bound<&[u8]>) -> ByteVecBound { Bound::Unbounded => Bound::Unbounded, } } + +// -- utils to encode table names -- + +fn encode_name(s: &str) -> Result { + let base = 'A' as u32; + + let mut ret = String::with_capacity(s.len() + 10); + for c in s.chars() { + if c.is_alphanumeric() || c == '_' || c == '-' || c == '#' { + ret.push(c); + } else if c <= u8::MAX as char { + ret.push('$'); + let c_hi = c as u32 / 16; + let c_lo = c as u32 % 16; + ret.push(char::from_u32(base + c_hi).unwrap()); + ret.push(char::from_u32(base + c_lo).unwrap()); + } else { + return Err(Error( + format!("table name {} could not be safely encoded", s).into(), + )); + } + } + Ok(ret) +} + +fn decode_name(s: &str) -> Result { + use std::convert::TryFrom; + + let errfn = || Error(format!("encoded table name {} is invalid", s).into()); + let c_map = |c: char| { + let c = c as u32; + let base = 'A' as u32; + if (base..base + 16).contains(&c) { + Some(c - base) + } else { + None + } + }; + + let mut ret = String::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c == '$' { + let c_hi = it.next().and_then(c_map).ok_or_else(errfn)?; + let c_lo = it.next().and_then(c_map).ok_or_else(errfn)?; + let c_dec = char::try_from(c_hi * 16 + c_lo).map_err(|_| errfn())?; + ret.push(c_dec); + } else { + ret.push(c); + } + } + Ok(ret) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encdec_name() { + for name in [ + "testname", + "test_name", + "test name", + "test$name", + "test:name@help.me$get/this**right", + ] { + let encname = encode_name(name).unwrap(); + assert!(!encname.contains(' ')); + assert!(!encname.contains('.')); + assert!(!encname.contains('*')); + assert_eq!(*name, decode_name(&encname).unwrap()); + } + } +} diff --git a/src/db/open.rs b/src/db/open.rs index 83ae1f93..fbd8d74a 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -1,6 +1,5 @@ use std::convert::TryInto; use std::path::PathBuf; -use std::sync::Arc; use crate::{Db, Error, Result}; @@ -128,13 +127,10 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { let fsync_ms = opt.fsync.then(|| 1000 as u16); let mut config = fjall::Config::new(path).fsync_ms(fsync_ms); if let Some(block_cache_size) = opt.fjall_block_cache_size { - let block_cache = Arc::new(fjall::BlockCache::with_capacity_bytes( - block_cache_size.try_into().unwrap(), - )); - config = config.block_cache(block_cache); + config = config.cache_size(block_cache_size.try_into().unwrap()); } let keyspace = config.open_transactional()?; - Ok(crate::fjall_adapter::FjallDb::init(path, keyspace)) + Ok(crate::fjall_adapter::FjallDb::init(keyspace)) } // Pattern is unreachable when all supported DB engines are compiled into binary. The allow diff --git a/src/db/test.rs b/src/db/test.rs index 26b816b8..08ce1dda 100644 --- a/src/db/test.rs +++ b/src/db/test.rs @@ -1,7 +1,7 @@ use crate::*; fn test_suite(db: Db) { - let tree = db.open_tree("tree").unwrap(); + let tree = db.open_tree("tree:this_is_a_tree").unwrap(); let ka: &[u8] = &b"test"[..]; let kb: &[u8] = &b"zwello"[..]; @@ -148,3 +148,15 @@ fn test_sqlite_db() { let db = SqliteDb::new(manager, false).unwrap(); test_suite(db); } + +#[test] +#[cfg(feature = "fjall")] +fn test_fjall_db() { + use crate::fjall_adapter::{fjall, FjallDb}; + + let path = mktemp::Temp::new_dir().unwrap(); + let config = fjall::Config::new(path); + let keyspace = config.open_transactional().unwrap(); + let db = FjallDb::init(keyspace); + test_suite(db); +} From 6ea86db8cd7687a766679526f10cf1cb42ae00b2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 27 Aug 2025 19:51:38 +0200 Subject: [PATCH 154/192] document fjall db engine, remove flakey metadata_fsync implementation --- doc/book/reference-manual/configuration.md | 10 ++++++++++ src/db/open.rs | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index 84aaf511..e134a83f 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -333,6 +333,7 @@ Since `v0.8.0`, Garage can use alternative storage backends as follows: | --------- | ----------------- | ------------- | | [LMDB](https://www.symas.com/lmdb) (since `v0.8.0`, default since `v0.9.0`) | `"lmdb"` | `/db.lmdb/` | | [Sqlite](https://sqlite.org) (since `v0.8.0`) | `"sqlite"` | `/db.sqlite` | +| [Fjall](https://github.com/fjall-rs/fjall) (**experimental support** since `v1.3.0`) | `"fjall"` | `/db.fjall/` | | [Sled](https://sled.rs) (old default, removed since `v1.0`) | `"sled"` | `/db/` | Sled was supported until Garage v0.9.x, and was removed in Garage v1.0. @@ -369,6 +370,14 @@ LMDB works very well, but is known to have the following limitations: so it is not the best choice for high-performance storage clusters, but it should work fine in many cases. +- Fjall: a storage engine based on LSM trees, which theoretically allow for + higher write throughput than other storage engines that are based on B-trees. + Using Fjall could potentially improve Garage's performance significantly in + write-heavy workloads. **Support for Fjall is experimental at this point**, + we have added it to Garage for evaluation purposes only. **Do not use it for + production-critical workloads.** + + It is possible to convert Garage's metadata directory from one format to another using the `garage convert-db` command, which should be used as follows: @@ -406,6 +415,7 @@ Here is how this option impacts the different database engines: |----------|------------------------------------|-------------------------------| | Sqlite | `PRAGMA synchronous = OFF` | `PRAGMA synchronous = NORMAL` | | LMDB | `MDB_NOMETASYNC` + `MDB_NOSYNC` | `MDB_NOMETASYNC` | +| Fjall | default options | not supported | Note that the Sqlite database is always ran in `WAL` mode (`PRAGMA journal_mode = WAL`). diff --git a/src/db/open.rs b/src/db/open.rs index fbd8d74a..d5469b58 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -124,8 +124,12 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { #[cfg(feature = "fjall")] Engine::Fjall => { info!("Opening Fjall database at: {}", path.display()); - let fsync_ms = opt.fsync.then(|| 1000 as u16); - let mut config = fjall::Config::new(path).fsync_ms(fsync_ms); + if opt.fsync { + return Err(Error( + "metadata_fsync is not supported with the Fjall database engine".into(), + )); + } + let mut config = fjall::Config::new(path); if let Some(block_cache_size) = opt.fjall_block_cache_size { config = config.cache_size(block_cache_size.try_into().unwrap()); } From 90bba5889aeeadfcd895ce4a245c3010bdcad01c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 27 Aug 2025 21:17:32 +0200 Subject: [PATCH 155/192] garage_db: rename len to approximate_len as it is used for stats only --- src/block/manager.rs | 6 +++--- src/block/metrics.rs | 6 +++--- src/block/resync.rs | 14 ++++++++------ src/db/fjall_adapter.rs | 8 ++++++-- src/db/lib.rs | 13 +++++++++---- src/db/lmdb_adapter.rs | 7 ++++++- src/db/sqlite_adapter.rs | 6 +++++- src/db/test.rs | 2 +- src/garage/admin/mod.rs | 19 ++++++++++++------- src/model/s3/lifecycle_worker.rs | 6 +++--- src/table/data.rs | 4 ++-- src/table/gc.rs | 2 +- src/table/merkle.rs | 10 +++++----- src/table/metrics.rs | 8 ++++---- src/table/queue.rs | 2 +- 15 files changed, 69 insertions(+), 44 deletions(-) diff --git a/src/block/manager.rs b/src/block/manager.rs index 41b2f02a..d1bf90d8 100644 --- a/src/block/manager.rs +++ b/src/block/manager.rs @@ -408,8 +408,8 @@ impl BlockManager { } /// Get number of items in the refcount table - pub fn rc_len(&self) -> Result { - Ok(self.rc.rc_table.len()?) + pub fn rc_approximate_len(&self) -> Result { + Ok(self.rc.rc_table.approximate_len()?) } /// Send command to start/stop/manager scrub worker @@ -427,7 +427,7 @@ impl BlockManager { /// List all resync errors pub fn list_resync_errors(&self) -> Result, Error> { - let mut blocks = Vec::with_capacity(self.resync.errors.len()?); + let mut blocks = Vec::with_capacity(self.resync.errors.approximate_len()?); for ent in self.resync.errors.iter()? { let (hash, cnt) = ent?; let cnt = ErrorCounter::decode(&cnt); diff --git a/src/block/metrics.rs b/src/block/metrics.rs index 2d41e365..c2ebb76b 100644 --- a/src/block/metrics.rs +++ b/src/block/metrics.rs @@ -50,7 +50,7 @@ impl BlockManagerMetrics { .init(), _rc_size: meter .u64_value_observer("block.rc_size", move |observer| { - if let Ok(value) = rc_tree.len() { + if let Ok(value) = rc_tree.approximate_len() { observer.observe(value as u64, &[]) } }) @@ -58,7 +58,7 @@ impl BlockManagerMetrics { .init(), _resync_queue_len: meter .u64_value_observer("block.resync_queue_length", move |observer| { - if let Ok(value) = resync_queue.len() { + if let Ok(value) = resync_queue.approximate_len() { observer.observe(value as u64, &[]); } }) @@ -68,7 +68,7 @@ impl BlockManagerMetrics { .init(), _resync_errored_blocks: meter .u64_value_observer("block.resync_errored_blocks", move |observer| { - if let Ok(value) = resync_errors.len() { + if let Ok(value) = resync_errors.approximate_len() { observer.observe(value as u64, &[]); } }) diff --git a/src/block/resync.rs b/src/block/resync.rs index b476a0b8..004f6b48 100644 --- a/src/block/resync.rs +++ b/src/block/resync.rs @@ -106,13 +106,13 @@ impl BlockResyncManager { } /// Get length of resync queue - pub fn queue_len(&self) -> Result { - Ok(self.queue.len()?) + pub fn queue_approximate_len(&self) -> Result { + Ok(self.queue.approximate_len()?) } /// Get number of blocks that have an error - pub fn errors_len(&self) -> Result { - Ok(self.errors.len()?) + pub fn errors_approximate_len(&self) -> Result { + Ok(self.errors.approximate_len()?) } /// Clear the error counter for a block and put it in queue immediately @@ -548,9 +548,11 @@ impl Worker for ResyncWorker { } WorkerStatus { - queue_length: Some(self.manager.resync.queue_len().unwrap_or(0) as u64), + queue_length: Some(self.manager.resync.queue_approximate_len().unwrap_or(0) as u64), tranquility: Some(tranquility), - persistent_errors: Some(self.manager.resync.errors_len().unwrap_or(0) as u64), + persistent_errors: Some( + self.manager.resync.errors_approximate_len().unwrap_or(0) as u64 + ), ..Default::default() } } diff --git a/src/db/fjall_adapter.rs b/src/db/fjall_adapter.rs index d91ef12f..d6a41e9e 100644 --- a/src/db/fjall_adapter.rs +++ b/src/db/fjall_adapter.rs @@ -132,10 +132,14 @@ impl IDb for FjallDb { } } - fn len(&self, tree_idx: usize) -> Result { + fn approximate_len(&self, tree_idx: usize) -> Result { + let tree = self.get_tree(tree_idx)?; + Ok(tree.approximate_len()) + } + fn is_empty(&self, tree_idx: usize) -> Result { let tree = self.get_tree(tree_idx)?; let tx = self.keyspace.read_tx(); - Ok(tx.len(&tree)?) + Ok(tx.is_empty(&tree)?) } fn insert(&self, tree_idx: usize, key: &[u8], value: &[u8]) -> Result<()> { diff --git a/src/db/lib.rs b/src/db/lib.rs index 3454c759..5ac16da8 100644 --- a/src/db/lib.rs +++ b/src/db/lib.rs @@ -154,7 +154,7 @@ impl Db { let tree_names = other.list_trees()?; for name in tree_names { let tree = self.open_tree(&name)?; - if tree.len()? > 0 { + if !tree.is_empty()? { return Err(Error(format!("tree {} already contains data", name).into())); } @@ -196,8 +196,12 @@ impl Tree { self.0.get(self.1, key.as_ref()) } #[inline] - pub fn len(&self) -> Result { - self.0.len(self.1) + pub fn approximate_len(&self) -> Result { + self.0.approximate_len(self.1) + } + #[inline] + pub fn is_empty(&self) -> Result { + self.0.is_empty(self.1) } #[inline] @@ -335,7 +339,8 @@ pub(crate) trait IDb: Send + Sync { fn snapshot(&self, path: &PathBuf) -> Result<()>; fn get(&self, tree: usize, key: &[u8]) -> Result>; - fn len(&self, tree: usize) -> Result; + fn approximate_len(&self, tree: usize) -> Result; + fn is_empty(&self, tree: usize) -> Result; fn insert(&self, tree: usize, key: &[u8], value: &[u8]) -> Result<()>; fn remove(&self, tree: usize, key: &[u8]) -> Result<()>; diff --git a/src/db/lmdb_adapter.rs b/src/db/lmdb_adapter.rs index bd85f1b4..cbbce2f8 100644 --- a/src/db/lmdb_adapter.rs +++ b/src/db/lmdb_adapter.rs @@ -126,11 +126,16 @@ impl IDb for LmdbDb { } } - fn len(&self, tree: usize) -> Result { + fn approximate_len(&self, tree: usize) -> Result { let tree = self.get_tree(tree)?; let tx = self.db.read_txn()?; Ok(tree.len(&tx)?.try_into().unwrap()) } + fn is_empty(&self, tree: usize) -> Result { + let tree = self.get_tree(tree)?; + let tx = self.db.read_txn()?; + Ok(tree.is_empty(&tx)?) + } fn insert(&self, tree: usize, key: &[u8], value: &[u8]) -> Result<()> { let tree = self.get_tree(tree)?; diff --git a/src/db/sqlite_adapter.rs b/src/db/sqlite_adapter.rs index ce6412b6..eee8b15d 100644 --- a/src/db/sqlite_adapter.rs +++ b/src/db/sqlite_adapter.rs @@ -160,7 +160,7 @@ impl IDb for SqliteDb { self.internal_get(&self.db.get()?, &tree, key) } - fn len(&self, tree: usize) -> Result { + fn approximate_len(&self, tree: usize) -> Result { let tree = self.get_tree(tree)?; let db = self.db.get()?; @@ -172,6 +172,10 @@ impl IDb for SqliteDb { } } + fn is_empty(&self, tree: usize) -> Result { + Ok(self.approximate_len(tree)? == 0) + } + fn insert(&self, tree: usize, key: &[u8], value: &[u8]) -> Result<()> { let tree = self.get_tree(tree)?; let db = self.db.get()?; diff --git a/src/db/test.rs b/src/db/test.rs index 08ce1dda..1e649719 100644 --- a/src/db/test.rs +++ b/src/db/test.rs @@ -14,7 +14,7 @@ fn test_suite(db: Db) { assert!(tree.insert(ka, va).is_ok()); assert_eq!(tree.get(ka).unwrap().unwrap(), va); - assert_eq!(tree.len().unwrap(), 1); + assert_eq!(tree.iter().unwrap().count(), 1); // ---- test transaction logic ---- diff --git a/src/garage/admin/mod.rs b/src/garage/admin/mod.rs index 3bbc2b86..6ae8fa88 100644 --- a/src/garage/admin/mod.rs +++ b/src/garage/admin/mod.rs @@ -219,7 +219,7 @@ impl AdminRpcHandler { // Gather block manager statistics writeln!(&mut ret, "\nBlock manager stats:").unwrap(); - let rc_len = self.garage.block_manager.rc_len()?.to_string(); + let rc_len = self.garage.block_manager.rc_approximate_len()?.to_string(); writeln!( &mut ret, @@ -230,13 +230,13 @@ impl AdminRpcHandler { writeln!( &mut ret, " resync queue length: {}", - self.garage.block_manager.resync.queue_len()? + self.garage.block_manager.resync.queue_approximate_len()? ) .unwrap(); writeln!( &mut ret, " blocks with resync errors: {}", - self.garage.block_manager.resync.errors_len()? + self.garage.block_manager.resync.errors_approximate_len()? ) .unwrap(); @@ -346,16 +346,21 @@ impl AdminRpcHandler { F: TableSchema + 'static, R: TableReplication + 'static, { - let data_len = t.data.store.len().map_err(GarageError::from)?.to_string(); - let mkl_len = t.merkle_updater.merkle_tree_len()?.to_string(); + let data_len = t + .data + .store + .approximate_len() + .map_err(GarageError::from)? + .to_string(); + let mkl_len = t.merkle_updater.merkle_tree_approximate_len()?.to_string(); Ok(format!( " {}\t{}\t{}\t{}\t{}", F::TABLE_NAME, data_len, mkl_len, - t.merkle_updater.todo_len()?, - t.data.gc_todo_len()? + t.merkle_updater.todo_approximate_len()?, + t.data.gc_todo_approximate_len()? )) } diff --git a/src/model/s3/lifecycle_worker.rs b/src/model/s3/lifecycle_worker.rs index bb10ba48..af00437e 100644 --- a/src/model/s3/lifecycle_worker.rs +++ b/src/model/s3/lifecycle_worker.rs @@ -121,13 +121,13 @@ impl Worker for LifecycleWorker { mpu_aborted, .. } => { - let n_objects = self.garage.object_table.data.store.len().ok(); + let n_objects = self.garage.object_table.data.store.approximate_len().ok(); let progress = match n_objects { - None => "...".to_string(), - Some(total) => format!( + Some(total) if total > 0 => format!( "~{:.2}%", 100. * std::cmp::min(*counter, total) as f32 / total as f32 ), + _ => "...".to_string(), }; WorkerStatus { progress: Some(progress), diff --git a/src/table/data.rs b/src/table/data.rs index 09f4e008..1d0308ce 100644 --- a/src/table/data.rs +++ b/src/table/data.rs @@ -367,7 +367,7 @@ impl TableData { } } - pub fn gc_todo_len(&self) -> Result { - Ok(self.gc_todo.len()?) + pub fn gc_todo_approximate_len(&self) -> Result { + Ok(self.gc_todo.approximate_len()?) } } diff --git a/src/table/gc.rs b/src/table/gc.rs index 28ea119d..1f30bd76 100644 --- a/src/table/gc.rs +++ b/src/table/gc.rs @@ -313,7 +313,7 @@ impl Worker for GcWorker { fn status(&self) -> WorkerStatus { WorkerStatus { - queue_length: Some(self.gc.data.gc_todo_len().unwrap_or(0) as u64), + queue_length: Some(self.gc.data.gc_todo_approximate_len().unwrap_or(0) as u64), ..Default::default() } } diff --git a/src/table/merkle.rs b/src/table/merkle.rs index 596d5805..7ba1f007 100644 --- a/src/table/merkle.rs +++ b/src/table/merkle.rs @@ -287,12 +287,12 @@ impl MerkleUpdater { MerkleNode::decode_opt(&ent) } - pub fn merkle_tree_len(&self) -> Result { - Ok(self.data.merkle_tree.len()?) + pub fn merkle_tree_approximate_len(&self) -> Result { + Ok(self.data.merkle_tree.approximate_len()?) } - pub fn todo_len(&self) -> Result { - Ok(self.data.merkle_todo.len()?) + pub fn todo_approximate_len(&self) -> Result { + Ok(self.data.merkle_todo.approximate_len()?) } } @@ -306,7 +306,7 @@ impl Worker for MerkleWorker { fn status(&self) -> WorkerStatus { WorkerStatus { - queue_length: Some(self.0.todo_len().unwrap_or(0) as u64), + queue_length: Some(self.0.todo_approximate_len().unwrap_or(0) as u64), ..Default::default() } } diff --git a/src/table/metrics.rs b/src/table/metrics.rs index 7bb0959a..78593202 100644 --- a/src/table/metrics.rs +++ b/src/table/metrics.rs @@ -34,7 +34,7 @@ impl TableMetrics { .u64_value_observer( "table.size", move |observer| { - if let Ok(value) = store.len() { + if let Ok(value) = store.approximate_len() { observer.observe( value as u64, &[KeyValue::new("table_name", table_name)], @@ -48,7 +48,7 @@ impl TableMetrics { .u64_value_observer( "table.merkle_tree_size", move |observer| { - if let Ok(value) = merkle_tree.len() { + if let Ok(value) = merkle_tree.approximate_len() { observer.observe( value as u64, &[KeyValue::new("table_name", table_name)], @@ -62,7 +62,7 @@ impl TableMetrics { .u64_value_observer( "table.merkle_updater_todo_queue_length", move |observer| { - if let Ok(v) = merkle_todo.len() { + if let Ok(v) = merkle_todo.approximate_len() { observer.observe( v as u64, &[KeyValue::new("table_name", table_name)], @@ -76,7 +76,7 @@ impl TableMetrics { .u64_value_observer( "table.gc_todo_queue_length", move |observer| { - if let Ok(value) = gc_todo.len() { + if let Ok(value) = gc_todo.approximate_len() { observer.observe( value as u64, &[KeyValue::new("table_name", table_name)], diff --git a/src/table/queue.rs b/src/table/queue.rs index ffe0a4a7..7ef1f16e 100644 --- a/src/table/queue.rs +++ b/src/table/queue.rs @@ -27,7 +27,7 @@ impl Worker for InsertQueueWorker { fn status(&self) -> WorkerStatus { WorkerStatus { - queue_length: Some(self.0.data.insert_queue.len().unwrap_or(0) as u64), + queue_length: Some(self.0.data.insert_queue.approximate_len().unwrap_or(0) as u64), ..Default::default() } } From 54b9bf02a34612e227920693730cf45d2bb7fa14 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 27 Aug 2025 23:03:09 +0200 Subject: [PATCH 156/192] garage_db: refactor open function --- src/db/fjall_adapter.rs | 25 +++++++++++-- src/db/lmdb_adapter.rs | 50 +++++++++++++++++++++++-- src/db/open.rs | 81 +++++++++++----------------------------- src/db/sqlite_adapter.rs | 21 +++++++++-- src/db/test.rs | 2 +- src/model/garage.rs | 13 +------ 6 files changed, 107 insertions(+), 85 deletions(-) diff --git a/src/db/fjall_adapter.rs b/src/db/fjall_adapter.rs index d6a41e9e..25913a1f 100644 --- a/src/db/fjall_adapter.rs +++ b/src/db/fjall_adapter.rs @@ -11,12 +11,30 @@ use fjall::{ }; use crate::{ + open::{Engine, OpenOpt}, Db, Error, IDb, ITx, ITxFn, OnCommit, Result, TxError, TxFnResult, TxOpError, TxOpResult, TxResult, TxValueIter, Value, ValueIter, }; pub use fjall; +// -- + +pub(crate) fn open_db(path: &PathBuf, opt: &OpenOpt) -> Result { + info!("Opening Fjall database at: {}", path.display()); + if opt.fsync { + return Err(Error( + "metadata_fsync is not supported with the Fjall database engine".into(), + )); + } + let mut config = fjall::Config::new(path); + if let Some(block_cache_size) = opt.fjall_block_cache_size { + config = config.cache_size(block_cache_size as u64); + } + let keyspace = config.open_transactional()?; + Ok(FjallDb::init(keyspace)) +} + // -- err impl From for Error { @@ -95,10 +113,9 @@ impl IDb for FjallDb { .collect::>>()?) } - fn snapshot(&self, to: &PathBuf) -> Result<()> { - std::fs::create_dir_all(to)?; - let mut path = to.clone(); - path.push("data.fjall"); + fn snapshot(&self, base_path: &PathBuf) -> Result<()> { + std::fs::create_dir_all(base_path)?; + let path = Engine::Fjall.db_path(base_path); let source_state = self.keyspace.read_tx(); let copy_keyspace = fjall::Config::new(path).open()?; diff --git a/src/db/lmdb_adapter.rs b/src/db/lmdb_adapter.rs index cbbce2f8..ac185ae9 100644 --- a/src/db/lmdb_adapter.rs +++ b/src/db/lmdb_adapter.rs @@ -11,12 +11,55 @@ use heed::types::ByteSlice; use heed::{BytesDecode, Env, RoTxn, RwTxn, UntypedDatabase as Database}; use crate::{ + open::{Engine, OpenOpt}, Db, Error, IDb, ITx, ITxFn, OnCommit, Result, TxError, TxFnResult, TxOpError, TxOpResult, TxResult, TxValueIter, Value, ValueIter, }; pub use heed; +// ---- top-level open function + +pub(crate) fn open_db(path: &PathBuf, opt: &OpenOpt) -> Result { + info!("Opening LMDB database at: {}", path.display()); + if let Err(e) = std::fs::create_dir_all(&path) { + return Err(Error( + format!("Unable to create LMDB data directory: {}", e).into(), + )); + } + + let map_size = match opt.lmdb_map_size { + None => recommended_map_size(), + Some(v) => v - (v % 4096), + }; + + let mut env_builder = heed::EnvOpenOptions::new(); + env_builder.max_dbs(100); + env_builder.map_size(map_size); + env_builder.max_readers(2048); + unsafe { + env_builder.flag(heed::flags::Flags::MdbNoRdAhead); + env_builder.flag(heed::flags::Flags::MdbNoMetaSync); + if !opt.fsync { + env_builder.flag(heed::flags::Flags::MdbNoSync); + } + } + match env_builder.open(&path) { + Err(heed::Error::Io(e)) if e.kind() == std::io::ErrorKind::OutOfMemory => { + return Err(Error( + "OutOfMemory error while trying to open LMDB database. This can happen \ + if your operating system is not allowing you to use sufficient virtual \ + memory address space. Please check that no limit is set (ulimit -v). \ + You may also try to set a smaller `lmdb_map_size` configuration parameter. \ + On 32-bit machines, you should probably switch to another database engine." + .into(), + )) + } + Err(e) => Err(Error(format!("Cannot open LMDB database: {}", e).into())), + Ok(db) => Ok(LmdbDb::init(db)), + } +} + // -- err impl From for Error { @@ -104,10 +147,9 @@ impl IDb for LmdbDb { Ok(ret2) } - fn snapshot(&self, to: &PathBuf) -> Result<()> { - std::fs::create_dir_all(to)?; - let mut path = to.clone(); - path.push("data.mdb"); + fn snapshot(&self, base_path: &PathBuf) -> Result<()> { + std::fs::create_dir_all(base_path)?; + let path = Engine::Lmdb.db_path(base_path); self.db .copy_to_path(path, heed::CompactionOption::Enabled)?; Ok(()) diff --git a/src/db/open.rs b/src/db/open.rs index d5469b58..23391c61 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -1,4 +1,3 @@ -use std::convert::TryInto; use std::path::PathBuf; use crate::{Db, Error, Result}; @@ -24,6 +23,23 @@ impl Engine { Self::Fjall => "fjall", } } + + /// Return engine-specific DB path from base path + pub fn db_path(&self, base_path: &PathBuf) -> PathBuf { + let mut ret = base_path.clone(); + match self { + Self::Lmdb => { + ret.push("db.lmdb"); + } + Self::Sqlite => { + ret.push("db.sqlite"); + } + Self::Fjall => { + ret.push("db.fjall"); + } + } + ret + } } impl std::fmt::Display for Engine { @@ -43,7 +59,7 @@ impl std::str::FromStr for Engine { "sled" => Err(Error("Sled is no longer supported as a database engine. Converting your old metadata db can be done using an older Garage binary (e.g. v0.9.4).".into())), kind => Err(Error( format!( - "Invalid DB engine: {} (options are: lmdb, sqlite)", + "Invalid DB engine: {} (options are: lmdb, sqlite, fjall)", kind ) .into(), @@ -72,70 +88,15 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { match engine { // ---- Sqlite DB ---- #[cfg(feature = "sqlite")] - Engine::Sqlite => { - info!("Opening Sqlite database at: {}", path.display()); - let manager = r2d2_sqlite::SqliteConnectionManager::file(path); - Ok(crate::sqlite_adapter::SqliteDb::new(manager, opt.fsync)?) - } + Engine::Sqlite => crate::sqlite_adapter::open_db(path, opt), // ---- LMDB DB ---- #[cfg(feature = "lmdb")] - Engine::Lmdb => { - info!("Opening LMDB database at: {}", path.display()); - if let Err(e) = std::fs::create_dir_all(&path) { - return Err(Error( - format!("Unable to create LMDB data directory: {}", e).into(), - )); - } - - let map_size = match opt.lmdb_map_size { - None => crate::lmdb_adapter::recommended_map_size(), - Some(v) => v - (v % 4096), - }; - - let mut env_builder = heed::EnvOpenOptions::new(); - env_builder.max_dbs(100); - env_builder.map_size(map_size); - env_builder.max_readers(2048); - unsafe { - env_builder.flag(crate::lmdb_adapter::heed::flags::Flags::MdbNoRdAhead); - env_builder.flag(crate::lmdb_adapter::heed::flags::Flags::MdbNoMetaSync); - if !opt.fsync { - env_builder.flag(heed::flags::Flags::MdbNoSync); - } - } - match env_builder.open(&path) { - Err(heed::Error::Io(e)) if e.kind() == std::io::ErrorKind::OutOfMemory => { - return Err(Error( - "OutOfMemory error while trying to open LMDB database. This can happen \ - if your operating system is not allowing you to use sufficient virtual \ - memory address space. Please check that no limit is set (ulimit -v). \ - You may also try to set a smaller `lmdb_map_size` configuration parameter. \ - On 32-bit machines, you should probably switch to another database engine." - .into(), - )) - } - Err(e) => Err(Error(format!("Cannot open LMDB database: {}", e).into())), - Ok(db) => Ok(crate::lmdb_adapter::LmdbDb::init(db)), - } - } + Engine::Lmdb => crate::lmdb_adapter::open_db(path, opt), // ---- Fjall DB ---- #[cfg(feature = "fjall")] - Engine::Fjall => { - info!("Opening Fjall database at: {}", path.display()); - if opt.fsync { - return Err(Error( - "metadata_fsync is not supported with the Fjall database engine".into(), - )); - } - let mut config = fjall::Config::new(path); - if let Some(block_cache_size) = opt.fjall_block_cache_size { - config = config.cache_size(block_cache_size.try_into().unwrap()); - } - let keyspace = config.open_transactional()?; - Ok(crate::fjall_adapter::FjallDb::init(keyspace)) - } + Engine::Fjall => crate::fjall_adapter::open_db(path, opt), // Pattern is unreachable when all supported DB engines are compiled into binary. The allow // attribute is added so that we won't have to change this match in case stop building diff --git a/src/db/sqlite_adapter.rs b/src/db/sqlite_adapter.rs index eee8b15d..5d86f178 100644 --- a/src/db/sqlite_adapter.rs +++ b/src/db/sqlite_adapter.rs @@ -11,12 +11,23 @@ use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{params, Rows, Statement, Transaction}; use crate::{ + open::{Engine, OpenOpt}, Db, Error, IDb, ITx, ITxFn, OnCommit, Result, TxError, TxFnResult, TxOpError, TxOpResult, TxResult, TxValueIter, Value, ValueIter, }; pub use rusqlite; +// ---- top-level open function + +pub(crate) fn open_db(path: &PathBuf, opt: &OpenOpt) -> Result { + info!("Opening Sqlite database at: {}", path.display()); + let manager = r2d2_sqlite::SqliteConnectionManager::file(path); + Ok(SqliteDb::new(manager, opt.fsync)?) +} + +// ---- + type Connection = r2d2::PooledConnection; // --- err @@ -139,17 +150,19 @@ impl IDb for SqliteDb { Ok(trees) } - fn snapshot(&self, to: &PathBuf) -> Result<()> { + fn snapshot(&self, base_path: &PathBuf) -> Result<()> { fn progress(p: rusqlite::backup::Progress) { let percent = (p.pagecount - p.remaining) * 100 / p.pagecount; info!("Sqlite snapshot progress: {}%", percent); } - std::fs::create_dir_all(to)?; - let mut path = to.clone(); - path.push("db.sqlite"); + + std::fs::create_dir_all(base_path)?; + let path = Engine::Sqlite.db_path(&base_path); + self.db .get()? .backup(rusqlite::DatabaseName::Main, path, Some(progress))?; + Ok(()) } diff --git a/src/db/test.rs b/src/db/test.rs index 1e649719..977dc965 100644 --- a/src/db/test.rs +++ b/src/db/test.rs @@ -155,7 +155,7 @@ fn test_fjall_db() { use crate::fjall_adapter::{fjall, FjallDb}; let path = mktemp::Temp::new_dir().unwrap(); - let config = fjall::Config::new(path); + let config = fjall::Config::new(path).temporary(true); let keyspace = config.open_transactional().unwrap(); let db = FjallDb::init(keyspace); test_suite(db); diff --git a/src/model/garage.rs b/src/model/garage.rs index 7420e740..38f8f1f7 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -116,18 +116,7 @@ impl Garage { info!("Opening database..."); let db_engine = db::Engine::from_str(&config.db_engine) .ok_or_message("Invalid `db_engine` value in configuration file")?; - let mut db_path = config.metadata_dir.clone(); - match db_engine { - db::Engine::Sqlite => { - db_path.push("db.sqlite"); - } - db::Engine::Lmdb => { - db_path.push("db.lmdb"); - } - db::Engine::Fjall => { - db_path.push("db.fjall"); - } - } + let db_path = db_engine.db_path(&config.metadata_dir); let db_opt = db::OpenOpt { fsync: config.metadata_fsync, lmdb_map_size: match config.lmdb_map_size { From c8c20d6f471a4263c187b778582e6df2bd7a08b3 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 28 Aug 2025 00:07:35 +0200 Subject: [PATCH 157/192] garage_db: reduce frequency of sqlite snapshot progress log (fix #1129) --- src/db/sqlite_adapter.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/db/sqlite_adapter.rs b/src/db/sqlite_adapter.rs index 5d86f178..d645c64e 100644 --- a/src/db/sqlite_adapter.rs +++ b/src/db/sqlite_adapter.rs @@ -152,8 +152,21 @@ impl IDb for SqliteDb { fn snapshot(&self, base_path: &PathBuf) -> Result<()> { fn progress(p: rusqlite::backup::Progress) { - let percent = (p.pagecount - p.remaining) * 100 / p.pagecount; - info!("Sqlite snapshot progress: {}%", percent); + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static LAST_LOG_TIME: AtomicU64 = AtomicU64::new(0); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Fix your clock :o") + .as_millis() as u64; + if now >= LAST_LOG_TIME.load(Ordering::Relaxed) + 10 * 1000 { + let percent = (p.pagecount - p.remaining) * 100 / p.pagecount; + info!("Sqlite snapshot progress: {}%", percent); + + LAST_LOG_TIME.fetch_max(now, Ordering::Relaxed); + } } std::fs::create_dir_all(base_path)?; From c8599a86360e72a40e0d8ada3e7b5802e943fe9e Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 4 Sep 2025 11:06:46 +0200 Subject: [PATCH 158/192] woodpecker: require the nix=enabled label --- .woodpecker/debug.yaml | 3 +++ .woodpecker/publish.yaml | 3 +++ .woodpecker/release.yaml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.woodpecker/debug.yaml b/.woodpecker/debug.yaml index 62266aa4..4c729672 100644 --- a/.woodpecker/debug.yaml +++ b/.woodpecker/debug.yaml @@ -1,3 +1,6 @@ +labels: + nix: "enabled" + when: event: - push diff --git a/.woodpecker/publish.yaml b/.woodpecker/publish.yaml index 7522d58d..24a84463 100644 --- a/.woodpecker/publish.yaml +++ b/.woodpecker/publish.yaml @@ -1,3 +1,6 @@ +labels: + nix: "enabled" + when: event: - deployment diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 0678a45b..bf2bd8ba 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -1,3 +1,6 @@ +labels: + nix: "enabled" + when: event: - deployment From 5cf354acb44872e782ff51b9da59df2838aa12f6 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 13 Sep 2025 17:38:06 +0200 Subject: [PATCH 159/192] block: maximum number of simultaneous reads --- doc/book/reference-manual/configuration.md | 25 ++++++++++++++++++++++ src/block/manager.rs | 16 ++++++++++++++ src/block/metrics.rs | 6 ++++++ src/util/config.rs | 7 ++++++ 4 files changed, 54 insertions(+) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index e134a83f..c6dce089 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -24,6 +24,7 @@ db_engine = "lmdb" block_size = "1M" block_ram_buffer_max = "256MiB" +block_max_concurrent_reads = 16 lmdb_map_size = "1T" @@ -96,6 +97,7 @@ The following gives details about each available configuration option. Top-level configuration options, in alphabetical order: [`allow_punycode`](#allow_punycode), [`allow_world_readable_secrets`](#allow_world_readable_secrets), +[`block_max_concurrent_reads`](`block_max_concurrent_reads), [`block_ram_buffer_max`](#block_ram_buffer_max), [`block_size`](#block_size), [`bootstrap_peers`](#bootstrap_peers), @@ -522,6 +524,29 @@ node. The default value is 256MiB. +#### `block_max_concurrent_reads` (since `v1.3.0` / `v2.1.0`) {#block_max_concurrent_reads} + +The maximum number of blocks (individual files in the data directory) open +simultaneously for reading. + +Reducing this number does not limit the number of data blocks that can be +transferred through the network simultaneously. This mechanism was just added +as a backpressure mechanism for HDD read speed: it helps avoid a situation +where too many requests are coming in and Garage is reading too many block +files simultaneously, thus not making timely progress on any of the reads. + +When a request to read a data block comes in through the network, the requests +awaits for one of the `block_max_concurrent_reads` slots to be available +(internally implemented using a Semaphore object). Once it acquired a read +slot, it reads the entire block file to RAM and frees the slot as soon as the +block file is finished reading. Only after the slot is released will the +block's data start being transferred over the network. If the request fails to +acquire a reading slot wihtin 15 seconds, it fails with a timeout error. +Timeout events can be monitored through the `block_read_semaphore_timeouts` +metric in Prometheus: a non-zero number of such events indicates an I/O +bottleneck on HDD read speed. + + #### `lmdb_map_size` {#lmdb_map_size} This parameters can be used to set the map size used by LMDB, diff --git a/src/block/manager.rs b/src/block/manager.rs index d1bf90d8..5ff9a138 100644 --- a/src/block/manager.rs +++ b/src/block/manager.rs @@ -50,6 +50,8 @@ pub const INLINE_THRESHOLD: usize = 3072; // to delete the block locally. pub(crate) const BLOCK_GC_DELAY: Duration = Duration::from_secs(600); +const BLOCK_READ_SEMAPHORE_TIMEOUT: Duration = Duration::from_secs(15); + /// RPC messages used to share blocks of data between nodes #[derive(Debug, Serialize, Deserialize)] pub enum BlockRpc { @@ -87,6 +89,7 @@ pub struct BlockManager { disable_scrub: bool, mutation_lock: Vec>, + read_semaphore: Semaphore, pub rc: BlockRc, pub resync: BlockResyncManager, @@ -176,6 +179,8 @@ impl BlockManager { .iter() .map(|_| Mutex::new(BlockManagerLocked())) .collect::>(), + + read_semaphore: Semaphore::new(config.block_max_concurrent_reads), rc, resync, system, @@ -581,6 +586,15 @@ impl BlockManager { ) -> Result { let (header, path) = block_path.as_parts_ref(); + let permit = tokio::select! { + sem = self.read_semaphore.acquire() => sem.ok_or_message("acquire read semaphore")?, + _ = tokio::time::sleep(BLOCK_READ_SEMAPHORE_TIMEOUT) => { + self.metrics.block_read_semaphore_timeouts.add(1); + debug!("read block {:?}: read_semaphore acquire timeout", hash); + return Err(Error::Message("read block: read_semaphore acquire timeout".into())); + } + }; + let mut f = fs::File::open(&path).await?; let mut data = vec![]; f.read_to_end(&mut data).await?; @@ -605,6 +619,8 @@ impl BlockManager { return Err(Error::CorruptData(*hash)); } + drop(permit); + Ok(data) } diff --git a/src/block/metrics.rs b/src/block/metrics.rs index c2ebb76b..81021fe1 100644 --- a/src/block/metrics.rs +++ b/src/block/metrics.rs @@ -22,6 +22,7 @@ pub struct BlockManagerMetrics { pub(crate) bytes_read: BoundCounter, pub(crate) block_read_duration: BoundValueRecorder, + pub(crate) block_read_semaphore_timeouts: BoundCounter, pub(crate) bytes_written: BoundCounter, pub(crate) block_write_duration: BoundValueRecorder, pub(crate) delete_counter: BoundCounter, @@ -119,6 +120,11 @@ impl BlockManagerMetrics { .with_description("Duration of block read operations") .init() .bind(&[]), + block_read_semaphore_timeouts: meter + .u64_counter("block.read_semaphore_timeouts") + .with_description("Number of block reads that failed due to semaphore acquire timeout") + .init() + .bind(&[]), bytes_written: meter .u64_counter("block.bytes_written") .with_description("Number of bytes written to disk") diff --git a/src/util/config.rs b/src/util/config.rs index 19c3e821..e351185f 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -75,6 +75,10 @@ pub struct Config { )] pub block_ram_buffer_max: usize, + /// Maximum number of concurrent reads of block files on disk + #[serde(default = "default_block_max_concurrent_reads")] + pub block_max_concurrent_reads: usize, + /// Skip the permission check of secret files. Useful when /// POSIX ACLs (or more complex chmods) are used. #[serde(default)] @@ -280,6 +284,9 @@ fn default_block_size() -> usize { fn default_block_ram_buffer_max() -> usize { 256 * 1024 * 1024 } +fn default_block_max_concurrent_reads() -> usize { + 16 +} fn default_consistency_mode() -> String { "consistent".into() From d5a57e3e130841f83fafeb973f4d13777b3f40d3 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 13 Sep 2025 17:38:23 +0200 Subject: [PATCH 160/192] block: read_block: don't add not found blocks to resync queue --- src/block/manager.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/block/manager.rs b/src/block/manager.rs index 5ff9a138..06cf9cbe 100644 --- a/src/block/manager.rs +++ b/src/block/manager.rs @@ -562,9 +562,6 @@ impl BlockManager { match self.find_block(hash).await { Some(p) => self.read_block_from(hash, &p).await, None => { - // Not found but maybe we should have had it ?? - self.resync - .put_to_resync(hash, 2 * self.system.rpc_helper().rpc_timeout())?; return Err(Error::Message(format!( "block {:?} not found on node", hash From 6cf6db5c6141e062560396086e7c6c80633f934c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 13 Sep 2025 17:49:25 +0200 Subject: [PATCH 161/192] fix panic when cluster_layout cannot be saved (fix #1150) --- src/rpc/layout/manager.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/rpc/layout/manager.rs b/src/rpc/layout/manager.rs index 21907ec7..bb8000bd 100644 --- a/src/rpc/layout/manager.rs +++ b/src/rpc/layout/manager.rs @@ -229,13 +229,11 @@ impl LayoutManager { } /// Save cluster layout data to disk - async fn save_cluster_layout(&self) -> Result<(), Error> { + async fn save_cluster_layout(&self) { let layout = self.layout.read().unwrap().inner().clone(); - self.persist_cluster_layout - .save_async(&layout) - .await - .expect("Cannot save current cluster layout"); - Ok(()) + if let Err(e) = self.persist_cluster_layout.save_async(&layout).await { + error!("Failed to save cluster_layout: {}", e); + } } fn broadcast_update(self: &Arc, rpc: SystemRpc) { @@ -313,7 +311,7 @@ impl LayoutManager { self.change_notify.notify_waiters(); self.broadcast_update(SystemRpc::AdvertiseClusterLayout(new_layout)); - self.save_cluster_layout().await?; + self.save_cluster_layout().await; } Ok(SystemRpc::Ok) @@ -328,7 +326,7 @@ impl LayoutManager { if let Some(new_trackers) = self.merge_layout_trackers(trackers) { self.change_notify.notify_waiters(); self.broadcast_update(SystemRpc::AdvertiseClusterLayoutTrackers(new_trackers)); - self.save_cluster_layout().await?; + self.save_cluster_layout().await; } Ok(SystemRpc::Ok) From 4c895a71862717ac0eed992d90a5cbd49508c430 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 14 Sep 2025 18:03:23 +0200 Subject: [PATCH 162/192] garage_db: fix error handling logic (fix #1138) --- src/db/lib.rs | 62 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/src/db/lib.rs b/src/db/lib.rs index 5ac16da8..71826255 100644 --- a/src/db/lib.rs +++ b/src/db/lib.rs @@ -106,32 +106,44 @@ impl Db { result: Cell::new(None), }; let tx_res = self.0.transaction(&f); - let ret = f - .result - .into_inner() - .expect("Transaction did not store result"); + let fn_res = f.result.into_inner(); - match tx_res { - Ok(on_commit) => match ret { - Ok(value) => { - on_commit.into_iter().for_each(|f| f()); - Ok(value) - } - _ => unreachable!(), - }, - Err(TxError::Abort(())) => match ret { - Err(TxError::Abort(e)) => Err(TxError::Abort(e)), - _ => unreachable!(), - }, - Err(TxError::Db(e2)) => match ret { - // Ok was stored -> the error occurred when finalizing - // transaction - Ok(_) => Err(TxError::Db(e2)), - // An error was already stored: that's the one we want to - // return - Err(TxError::Db(e)) => Err(TxError::Db(e)), - _ => unreachable!(), - }, + match (tx_res, fn_res) { + (Ok(on_commit), Some(Ok(value))) => { + // Transaction succeeded + // TxFn stored the value to return to the user in fn_res + // tx_res contains the on_commit list of callbacks, run them now + on_commit.into_iter().for_each(|f| f()); + Ok(value) + } + (Err(TxError::Abort(())), Some(Err(TxError::Abort(e)))) => { + // Transaction was aborted by user code + // The abort error value is stored in fn_res + Err(TxError::Abort(e)) + } + (Err(TxError::Db(_tx_e)), Some(Err(TxError::Db(fn_e)))) => { + // Transaction encountered a DB error in user code + // The error value encountered is the one in fn_res, + // tx_res contains only a dummy error message + Err(TxError::Db(fn_e)) + } + (Err(TxError::Db(tx_e)), None) => { + // Transaction encounterred a DB error when initializing the transaction, + // before user code was called + Err(TxError::Db(tx_e)) + } + (Err(TxError::Db(tx_e)), Some(Ok(_))) => { + // Transaction encounterred a DB error when commiting the transaction, + // after user code was called + Err(TxError::Db(tx_e)) + } + (tx_res, fn_res) => { + panic!( + "unexpected error case: tx_res={:?}, fn_res={:?}", + tx_res.map(|_| "..."), + fn_res.map(|x| x.map(|_| "...").map_err(|_| "...")) + ); + } } } From 60b1d78b562f5eb3fa6a81faee943d2207f9f4ef Mon Sep 17 00:00:00 2001 From: Lapineige Date: Fri, 1 Aug 2025 21:55:00 +0000 Subject: [PATCH 163/192] Add Plakar documentation --- doc/book/connect/backup.md | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/doc/book/connect/backup.md b/doc/book/connect/backup.md index f39cc3b6..7e97d777 100644 --- a/doc/book/connect/backup.md +++ b/doc/book/connect/backup.md @@ -161,3 +161,49 @@ kopia repository validate-provider You can then run all the standard kopia commands: `kopia snapshot create`, `kopia mount`... Everything should work out-of-the-box. + +## Plakar + +Create your key and bucket on Garage server: + +```bash +garage key create my-plakar-key +garage bucket create plakar-backups +garage bucket allow plakar-backups --read --write --key my-plakar-key +… +``` + +On Plakar server, add your Garage as a storage location: +```bash +plakar store add garageS3 s3://my-garage.tld/plakar-backups \ +region=garage # Or as you've specified in garage.toml \ +access_key= \ +secret_access_key= +``` + +Then create the repository. +```bash +plakar at @garageS3 create -plaintext # Unencrypted +# or +plakar at @garageS3 create #encrypted +``` + +If you encrypt your backups (Plakar default), you will need to define a strong passphrase. Do not forget to save your password safely. It will be needed to decrypt your backups. + + +After the repository has been created, check that everything works as expected (that might give an empty result as no file has been added yet, but no error message): +```bash +plakar at @garageS3 check +``` + +Now that everything is configure, you can use Garage as your backups storage. For instance sync it with a local backup storage: +```bash +$ plakar at ~/backups sync to @garageS3 +``` + +Or list the S3 storage content: +```bash +$ plakar at @garageS3 ls +``` + +More information in Plakar documentation: https://www.plakar.io/docs/main/quickstart/ From 5687fc0375375a85a6b939845f270135ca289959 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 14 Sep 2025 19:22:36 +0200 Subject: [PATCH 164/192] update rusqlite and snapshot using VACUUM INTO --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 4 ++-- src/db/sqlite_adapter.rs | 30 ++++++++---------------------- 3 files changed, 19 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 997d6a92..5824294e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1810,11 +1810,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.2", ] [[package]] @@ -2619,9 +2619,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ "cc", "pkg-config", @@ -3478,9 +3478,9 @@ dependencies = [ [[package]] name = "r2d2_sqlite" -version = "0.24.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a982edf65c129796dba72f8775b292ef482b40d035e827a9825b3bc07ccc5f2" +checksum = "63417e83dc891797eea3ad379f52a5986da4bca0d6ef28baf4d14034dd111b0c" dependencies = [ "r2d2", "rusqlite", @@ -3669,9 +3669,9 @@ checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" [[package]] name = "rusqlite" -version = "0.31.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ "bitflags 2.9.0", "fallible-iterator", diff --git a/Cargo.toml b/Cargo.toml index fdec5010..75d5bcfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,9 +88,9 @@ tracing-journald = "0.3.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } heed = { version = "0.11", default-features = false, features = ["lmdb"] } -rusqlite = "0.31.0" +rusqlite = "0.37" r2d2 = "0.8" -r2d2_sqlite = "0.24" +r2d2_sqlite = "0.31" fjall = "2.4" async-compression = { version = "0.4", features = ["tokio", "zstd"] } diff --git a/src/db/sqlite_adapter.rs b/src/db/sqlite_adapter.rs index d645c64e..a03ee8ef 100644 --- a/src/db/sqlite_adapter.rs +++ b/src/db/sqlite_adapter.rs @@ -151,30 +151,16 @@ impl IDb for SqliteDb { } fn snapshot(&self, base_path: &PathBuf) -> Result<()> { - fn progress(p: rusqlite::backup::Progress) { - use std::sync::atomic::{AtomicU64, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static LAST_LOG_TIME: AtomicU64 = AtomicU64::new(0); - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Fix your clock :o") - .as_millis() as u64; - if now >= LAST_LOG_TIME.load(Ordering::Relaxed) + 10 * 1000 { - let percent = (p.pagecount - p.remaining) * 100 / p.pagecount; - info!("Sqlite snapshot progress: {}%", percent); - - LAST_LOG_TIME.fetch_max(now, Ordering::Relaxed); - } - } - std::fs::create_dir_all(base_path)?; - let path = Engine::Sqlite.db_path(&base_path); + let path = Engine::Sqlite + .db_path(&base_path) + .into_os_string() + .into_string() + .map_err(|_| Error("invalid sqlite path string".into()))?; - self.db - .get()? - .backup(rusqlite::DatabaseName::Main, path, Some(progress))?; + info!("Start sqlite VACUUM INTO `{}`", path); + self.db.get()?.execute("VACUUM INTO ?1", params![path])?; + info!("Finished sqlite VACUUM INTO `{}`", path); Ok(()) } From d726cf02997417f6f03ce2fe6e7d7886204cb633 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 14 Sep 2025 19:34:44 +0200 Subject: [PATCH 165/192] add `garage repair clear-resync-queue` (fix #1151) --- src/block/resync.rs | 8 ++++++++ src/garage/cli/structs.rs | 4 ++++ src/garage/repair/online.rs | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/src/block/resync.rs b/src/block/resync.rs index 004f6b48..7056a828 100644 --- a/src/block/resync.rs +++ b/src/block/resync.rs @@ -133,6 +133,14 @@ impl BlockResyncManager { ))) } + /// Clear the entire resync queue and list of errored blocks + /// Corresponds to `garage repair clear-resync-queue` + pub fn clear_resync_queue(&self) -> Result<(), Error> { + self.queue.clear()?; + self.errors.clear()?; + Ok(()) + } + pub fn register_bg_vars(&self, vars: &mut vars::BgVars) { let notify = self.notify.clone(); vars.register_rw( diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 3652ef6b..386a213b 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -466,6 +466,10 @@ pub enum RepairWhat { /// Repair (resync/rebalance) the set of stored blocks in the cluster #[structopt(name = "blocks", version = garage_version())] Blocks, + /// Clear the block resync queue. The list of blocks in errored state + /// is cleared as well. You MUST run `garage repair blocks` after invoking this. + #[structopt(name = "clear-resync-queue", version = garage_version())] + ClearResyncQueue, /// Repropagate object deletions to the version table #[structopt(name = "versions", version = garage_version())] Versions, diff --git a/src/garage/repair/online.rs b/src/garage/repair/online.rs index 950cd5f7..6a7dafcf 100644 --- a/src/garage/repair/online.rs +++ b/src/garage/repair/online.rs @@ -92,6 +92,11 @@ pub async fn launch_online_repair( info!("Repairing bucket aliases (foreground)"); garage.locked_helper().await.repair_aliases().await?; } + RepairWhat::ClearResyncQueue => { + let garage = garage.clone(); + tokio::task::spawn_blocking(move || garage.block_manager.resync.clear_resync_queue()) + .await?? + } } Ok(()) } From 0f1b488be051f08e49ae0fcc03cc34cb608fc2bf Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 14 Sep 2025 21:25:37 +0200 Subject: [PATCH 166/192] fix rust warnings --- src/api/common/generic_server.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/common/generic_server.rs b/src/api/common/generic_server.rs index 5783c276..3f14c07d 100644 --- a/src/api/common/generic_server.rs +++ b/src/api/common/generic_server.rs @@ -33,7 +33,6 @@ use garage_util::metrics::{gen_trace_id, RecordDuration}; use garage_util::socket_address::UnixOrTCPSocketAddress; use crate::helpers::{BoxBody, ErrorBody}; -use crate::signature::payload::Authorization; pub trait ApiEndpoint: Send + Sync + 'static { fn name(&self) -> &'static str; @@ -62,7 +61,7 @@ pub trait ApiHandler: Send + Sync + 'static { /// Returns the key id used to authenticate this request. The ID returned must be safe to /// log. - fn key_id_from_request(&self, req: &Request) -> Option { + fn key_id_from_request(&self, _req: &Request) -> Option { None } } From 4b1fdbef55ee6a6bd68e904aa91863e7c3289555 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 14 Sep 2025 21:36:33 +0200 Subject: [PATCH 167/192] bump version to v1.3.0 --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 24 ++++++++++++------------ doc/book/cookbook/real-world.md | 10 +++++----- doc/book/quick-start/_index.md | 2 +- doc/drafts/admin-api.md | 2 +- script/helm/garage/Chart.yaml | 4 ++-- script/helm/garage/README.md | 2 +- src/api/admin/Cargo.toml | 2 +- src/api/common/Cargo.toml | 2 +- src/api/k2v/Cargo.toml | 2 +- src/api/s3/Cargo.toml | 2 +- src/block/Cargo.toml | 2 +- src/db/Cargo.toml | 2 +- src/garage/Cargo.toml | 2 +- src/model/Cargo.toml | 4 ++-- src/net/Cargo.toml | 2 +- src/rpc/Cargo.toml | 2 +- src/table/Cargo.toml | 2 +- src/util/Cargo.toml | 2 +- src/web/Cargo.toml | 2 +- 20 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5824294e..c516bfbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "garage" -version = "1.2.0" +version = "1.3.0" dependencies = [ "assert-json-diff", "async-trait", @@ -1336,7 +1336,7 @@ dependencies = [ [[package]] name = "garage_api_admin" -version = "1.2.0" +version = "1.3.0" dependencies = [ "argon2", "async-trait", @@ -1362,7 +1362,7 @@ dependencies = [ [[package]] name = "garage_api_common" -version = "1.2.0" +version = "1.3.0" dependencies = [ "base64 0.21.7", "bytes", @@ -1396,7 +1396,7 @@ dependencies = [ [[package]] name = "garage_api_k2v" -version = "1.2.0" +version = "1.3.0" dependencies = [ "base64 0.21.7", "err-derive", @@ -1419,7 +1419,7 @@ dependencies = [ [[package]] name = "garage_api_s3" -version = "1.2.0" +version = "1.3.0" dependencies = [ "aes-gcm", "async-compression", @@ -1464,7 +1464,7 @@ dependencies = [ [[package]] name = "garage_block" -version = "1.2.0" +version = "1.3.0" dependencies = [ "arc-swap", "async-compression", @@ -1489,7 +1489,7 @@ dependencies = [ [[package]] name = "garage_db" -version = "1.2.0" +version = "1.3.0" dependencies = [ "err-derive", "fjall", @@ -1504,7 +1504,7 @@ dependencies = [ [[package]] name = "garage_model" -version = "1.2.0" +version = "1.3.0" dependencies = [ "async-trait", "base64 0.21.7", @@ -1531,7 +1531,7 @@ dependencies = [ [[package]] name = "garage_net" -version = "1.2.0" +version = "1.3.0" dependencies = [ "arc-swap", "bytes", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "garage_rpc" -version = "1.2.0" +version = "1.3.0" dependencies = [ "arc-swap", "async-trait", @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "garage_table" -version = "1.2.0" +version = "1.3.0" dependencies = [ "arc-swap", "async-trait", @@ -1609,7 +1609,7 @@ dependencies = [ [[package]] name = "garage_util" -version = "1.2.0" +version = "1.3.0" dependencies = [ "arc-swap", "async-trait", @@ -1641,7 +1641,7 @@ dependencies = [ [[package]] name = "garage_web" -version = "1.2.0" +version = "1.3.0" dependencies = [ "err-derive", "garage_api_common", diff --git a/Cargo.toml b/Cargo.toml index 75d5bcfb..5bc76e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,18 +24,18 @@ default-members = ["src/garage"] # Internal Garage crates format_table = { version = "0.1.1", path = "src/format-table" } -garage_api_common = { version = "1.2.0", path = "src/api/common" } -garage_api_admin = { version = "1.2.0", path = "src/api/admin" } -garage_api_s3 = { version = "1.2.0", path = "src/api/s3" } -garage_api_k2v = { version = "1.2.0", path = "src/api/k2v" } -garage_block = { version = "1.2.0", path = "src/block" } -garage_db = { version = "1.2.0", path = "src/db", default-features = false } -garage_model = { version = "1.2.0", path = "src/model", default-features = false } -garage_net = { version = "1.2.0", path = "src/net" } -garage_rpc = { version = "1.2.0", path = "src/rpc" } -garage_table = { version = "1.2.0", path = "src/table" } -garage_util = { version = "1.2.0", path = "src/util" } -garage_web = { version = "1.2.0", path = "src/web" } +garage_api_common = { version = "1.3.0", path = "src/api/common" } +garage_api_admin = { version = "1.3.0", path = "src/api/admin" } +garage_api_s3 = { version = "1.3.0", path = "src/api/s3" } +garage_api_k2v = { version = "1.3.0", path = "src/api/k2v" } +garage_block = { version = "1.3.0", path = "src/block" } +garage_db = { version = "1.3.0", path = "src/db", default-features = false } +garage_model = { version = "1.3.0", path = "src/model", default-features = false } +garage_net = { version = "1.3.0", path = "src/net" } +garage_rpc = { version = "1.3.0", path = "src/rpc" } +garage_table = { version = "1.3.0", path = "src/table" } +garage_util = { version = "1.3.0", path = "src/util" } +garage_web = { version = "1.3.0", path = "src/web" } k2v-client = { version = "0.0.4", path = "src/k2v-client" } # External crates from crates.io diff --git a/doc/book/cookbook/real-world.md b/doc/book/cookbook/real-world.md index 998c02a5..b9927c06 100644 --- a/doc/book/cookbook/real-world.md +++ b/doc/book/cookbook/real-world.md @@ -96,14 +96,14 @@ to store 2 TB of data in total. ## Get a Docker image Our docker image is currently named `dxflrs/garage` and is stored on the [Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated). -We encourage you to use a fixed tag (eg. `v1.2.0`) and not the `latest` tag. -For this example, we will use the latest published version at the time of the writing which is `v1.2.0` but it's up to you +We encourage you to use a fixed tag (eg. `v1.3.0`) and not the `latest` tag. +For this example, we will use the latest published version at the time of the writing which is `v1.3.0` but it's up to you to check [the most recent versions on the Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated). For example: ``` -sudo docker pull dxflrs/garage:v1.2.0 +sudo docker pull dxflrs/garage:v1.3.0 ``` ## Deploying and configuring Garage @@ -171,7 +171,7 @@ docker run \ -v /etc/garage.toml:/etc/garage.toml \ -v /var/lib/garage/meta:/var/lib/garage/meta \ -v /var/lib/garage/data:/var/lib/garage/data \ - dxflrs/garage:v1.2.0 + dxflrs/garage:v1.3.0 ``` With this command line, Garage should be started automatically at each boot. @@ -185,7 +185,7 @@ If you want to use `docker-compose`, you may use the following `docker-compose.y version: "3" services: garage: - image: dxflrs/garage:v1.2.0 + image: dxflrs/garage:v1.3.0 network_mode: "host" restart: unless-stopped volumes: diff --git a/doc/book/quick-start/_index.md b/doc/book/quick-start/_index.md index 45a4a43b..633b785a 100644 --- a/doc/book/quick-start/_index.md +++ b/doc/book/quick-start/_index.md @@ -132,7 +132,7 @@ docker run \ -v /path/to/garage.toml:/etc/garage.toml \ -v /path/to/garage/meta:/var/lib/garage/meta \ -v /path/to/garage/data:/var/lib/garage/data \ - dxflrs/garage:v1.2.0 + dxflrs/garage:v1.3.0 ``` Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903` diff --git a/doc/drafts/admin-api.md b/doc/drafts/admin-api.md index a3d03c41..3ee948cb 100644 --- a/doc/drafts/admin-api.md +++ b/doc/drafts/admin-api.md @@ -70,7 +70,7 @@ Example response body: ```json { "node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df", - "garageVersion": "v1.2.0", + "garageVersion": "v1.3.0", "garageFeatures": [ "k2v", "lmdb", diff --git a/script/helm/garage/Chart.yaml b/script/helm/garage/Chart.yaml index 6806e593..51f98bbb 100644 --- a/script/helm/garage/Chart.yaml +++ b/script/helm/garage/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: garage description: S3-compatible object store for small self-hosted geo-distributed deployments type: application -version: 0.7.1 -appVersion: "v1.2.0" +version: 0.7.2 +appVersion: "v1.3.0" home: https://garagehq.deuxfleurs.fr/ icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg diff --git a/script/helm/garage/README.md b/script/helm/garage/README.md index 05d444a3..25e548ec 100644 --- a/script/helm/garage/README.md +++ b/script/helm/garage/README.md @@ -1,6 +1,6 @@ # garage -![Version: 0.7.1](https://img.shields.io/badge/Version-0.7.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.2.0](https://img.shields.io/badge/AppVersion-v1.2.0-informational?style=flat-square) +![Version: 0.7.2](https://img.shields.io/badge/Version-0.7.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.3.0](https://img.shields.io/badge/AppVersion-v1.3.0-informational?style=flat-square) S3-compatible object store for small self-hosted geo-distributed deployments diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 6b039eeb..d7184068 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_admin" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index a67e9d9c..fd159c96 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_common" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/k2v/Cargo.toml b/src/api/k2v/Cargo.toml index 845d23f6..628d2db1 100644 --- a/src/api/k2v/Cargo.toml +++ b/src/api/k2v/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_k2v" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 1ba7565d..15f6858c 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_s3" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/block/Cargo.toml b/src/block/Cargo.toml index d5f8e58e..effa8dba 100644 --- a/src/block/Cargo.toml +++ b/src/block/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_block" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index e9ed15c9..6dee2fa6 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_db" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index 7d60313e..ad2b917b 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 14f92253..e59765d7 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_model" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" @@ -44,4 +44,4 @@ default = [ "lmdb", "sqlite" ] k2v = [ "garage_util/k2v" ] lmdb = [ "garage_db/lmdb" ] sqlite = [ "garage_db/sqlite" ] -fjall = [ "garage_db/fjall" ] \ No newline at end of file +fjall = [ "garage_db/fjall" ] diff --git a/src/net/Cargo.toml b/src/net/Cargo.toml index 17a0eb24..83b3b15b 100644 --- a/src/net/Cargo.toml +++ b/src/net/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_net" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/rpc/Cargo.toml b/src/rpc/Cargo.toml index a314271f..1e764c77 100644 --- a/src/rpc/Cargo.toml +++ b/src/rpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_rpc" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/table/Cargo.toml b/src/table/Cargo.toml index c76c5b78..91ab110c 100644 --- a/src/table/Cargo.toml +++ b/src/table/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_table" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/util/Cargo.toml b/src/util/Cargo.toml index f59e44e2..0d693a97 100644 --- a/src/util/Cargo.toml +++ b/src/util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_util" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index 5d208e6e..c1056509 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_web" -version = "1.2.0" +version = "1.3.0" authors = ["Alex Auvolat ", "Quentin Dufour "] edition = "2018" license = "AGPL-3.0" From 42fd8583bd81c9855d25c5ac0171e9eb9d265ab7 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Wed, 8 Oct 2025 17:54:15 +0200 Subject: [PATCH 168/192] properly handle precondition time equal to object time --- src/api/s3/get.rs | 4 +++- src/garage/tests/s3/objects.rs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index 888a040a..a1e4ce10 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -845,7 +845,9 @@ impl PreconditionHeaders { } fn check(&self, v: &ObjectVersion, etag: &str) -> Result, Error> { - let v_date = UNIX_EPOCH + Duration::from_millis(v.timestamp); + // we store date with ms precision, but headers are precise to the second: truncate + // the timestamp to handle the same-second edge case + let v_date = UNIX_EPOCH + Duration::from_secs(v.timestamp / 1000); // Implemented from https://datatracker.ietf.org/doc/html/rfc7232#section-6 diff --git a/src/garage/tests/s3/objects.rs b/src/garage/tests/s3/objects.rs index d63ac000..53e8231d 100644 --- a/src/garage/tests/s3/objects.rs +++ b/src/garage/tests/s3/objects.rs @@ -198,6 +198,7 @@ async fn test_precondition() { ); } let older_date = DateTime::from_secs_f64(last_modified.as_secs_f64() - 10.0); + let same_date = DateTime::from_secs_f64(last_modified.as_secs_f64()); let newer_date = DateTime::from_secs_f64(last_modified.as_secs_f64() + 10.0); { let err = ctx @@ -212,6 +213,18 @@ async fn test_precondition() { matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 304) ); + let err = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_modified_since(same_date) + .send() + .await; + assert!( + matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 304) + ); + let o = ctx .client .get_object() @@ -236,6 +249,17 @@ async fn test_precondition() { matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 412) ); + let o = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_unmodified_since(same_date) + .send() + .await + .unwrap(); + assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag); + let o = ctx .client .get_object() From 1c29d04cc5c05aae1b0b7928e573c8f1ac6d65e4 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 14 Oct 2025 11:16:35 +0200 Subject: [PATCH 169/192] sigv4: don't enforce x-amz-content-sha256 to be in signed headers list (fix #770) From the following page: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html > In both cases, because the x-amz-content-sha256 header value is already > part of your HashedPayload, you are not required to include the > x-amz-content-sha256 header as a canonical header. --- src/api/common/signature/payload.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/common/signature/payload.rs b/src/api/common/signature/payload.rs index c3a7f231..3939da19 100644 --- a/src/api/common/signature/payload.rs +++ b/src/api/common/signature/payload.rs @@ -104,7 +104,7 @@ async fn check_standard_signature( // Verify that all necessary request headers are included in signed_headers // The following must be included for all signatures: // - the Host header (mandatory) - // - all x-amz-* headers used in the request + // - all x-amz-* headers used in the request (except x-amz-content-sha256) // AWS also indicates that the Content-Type header should be signed if // it is used, but Minio client doesn't sign it so we don't check it for compatibility. let signed_headers = split_signed_headers(&authorization)?; @@ -151,7 +151,7 @@ async fn check_presigned_signature( // Verify that all necessary request headers are included in signed_headers // For AWSv4 pre-signed URLs, the following must be included: // - the Host header (mandatory) - // - all x-amz-* headers used in the request + // - all x-amz-* headers used in the request (except x-amz-content-sha256) let signed_headers = split_signed_headers(&authorization)?; verify_signed_headers(request.headers(), &signed_headers)?; @@ -268,7 +268,9 @@ fn verify_signed_headers(headers: &HeaderMap, signed_headers: &[HeaderName]) -> return Err(Error::bad_request("Header `Host` should be signed")); } for (name, _) in headers.iter() { - if name.as_str().starts_with("x-amz-") { + // Enforce signature of all x-amz-* headers, except x-amz-content-sh256 + // because it is included in the canonical request in all cases + if name.as_str().starts_with("x-amz-") && name != X_AMZ_CONTENT_SHA256 { if !signed_headers.contains(name) { return Err(Error::bad_request(format!( "Header `{}` should be signed", @@ -468,8 +470,7 @@ impl Authorization { let date = headers .get(X_AMZ_DATE) - .ok_or_bad_request("Missing X-Amz-Date field") - .map_err(Error::from)? + .ok_or_bad_request("Missing X-Amz-Date field")? .to_str()?; let date = parse_date(date)?; From b43c58cbe553deb970fe403316d621ec57f0fac0 Mon Sep 17 00:00:00 2001 From: fgberry Date: Fri, 24 Oct 2025 11:22:32 +0200 Subject: [PATCH 170/192] fix: default config path changed for alpine binary --- doc/book/cookbook/binary-packages.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/book/cookbook/binary-packages.md b/doc/book/cookbook/binary-packages.md index 0a6ad8fc..8c30b360 100644 --- a/doc/book/cookbook/binary-packages.md +++ b/doc/book/cookbook/binary-packages.md @@ -15,8 +15,10 @@ Alpine Linux repositories (available since v3.17): apk add garage ``` -The default configuration file is installed to `/etc/garage.toml`. You can run -Garage using: `rc-service garage start`. If you don't specify `rpc_secret`, it +The default configuration file is installed to `/etc/garage/garage.toml`. You can run +Garage using: `rc-service garage start`. + +If you don't specify `rpc_secret`, it will be automatically replaced with a random string on the first start. Please note that this package is built without Consul discovery, Kubernetes From 1aac7b4875b1acde44e98b6a98b343cb659572f5 Mon Sep 17 00:00:00 2001 From: fgberry Date: Fri, 24 Oct 2025 11:25:33 +0200 Subject: [PATCH 171/192] chore: spacing --- doc/book/cookbook/binary-packages.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/book/cookbook/binary-packages.md b/doc/book/cookbook/binary-packages.md index 8c30b360..6476ff51 100644 --- a/doc/book/cookbook/binary-packages.md +++ b/doc/book/cookbook/binary-packages.md @@ -18,8 +18,7 @@ apk add garage The default configuration file is installed to `/etc/garage/garage.toml`. You can run Garage using: `rc-service garage start`. -If you don't specify `rpc_secret`, it -will be automatically replaced with a random string on the first start. +If you don't specify `rpc_secret`, it will be automatically replaced with a random string on the first start. Please note that this package is built without Consul discovery, Kubernetes discovery, OpenTelemetry exporter, and K2V features (K2V will be enabled once From 174f4f01a8d2daff681152d62a1a23c5fe7bcc9e Mon Sep 17 00:00:00 2001 From: teo-tsirpanis Date: Sun, 26 Oct 2025 15:54:08 +0000 Subject: [PATCH 172/192] Update link to signature v2. --- doc/book/reference-manual/s3-compatibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/book/reference-manual/s3-compatibility.md b/doc/book/reference-manual/s3-compatibility.md index edf8de0d..b869b6f4 100644 --- a/doc/book/reference-manual/s3-compatibility.md +++ b/doc/book/reference-manual/s3-compatibility.md @@ -27,7 +27,7 @@ Feel free to open a PR to suggest fixes this table. Minio is missing because the | Feature | Garage | [Openstack Swift](https://docs.openstack.org/swift/latest/s3_compat.html) | [Ceph Object Gateway](https://docs.ceph.com/en/latest/radosgw/s3/) | [Riak CS](https://docs.riak.com/riak/cs/2.1.1/references/apis/storage/s3/index.html) | [OpenIO](https://docs.openio.io/latest/source/arch-design/s3_compliancy.html) | |------------------------------|----------------------------------|-----------------|---------------|---------|-----| -| [signature v2](https://docs.aws.amazon.com/general/latest/gr/signature-version-2.html) (deprecated) | ❌ Missing | ✅ | ✅ | ✅ | ✅ | +| [signature v2](https://docs.aws.amazon.com/AmazonS3/latest/API/Appendix-Sigv2.html) (deprecated) | ❌ Missing | ✅ | ✅ | ✅ | ✅ | | [signature v4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) | ✅ Implemented | ✅ | ✅ | ❌ | ✅ | | [URL path-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access) (eg. `host.tld/bucket/key`) | ✅ Implemented | ✅ | ✅ | ❓| ✅ | | [URL vhost-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access) URL (eg. `bucket.host.tld/key`) | ✅ Implemented | ❌| ✅| ✅ | ✅ | From 82297371bff85476ad10d05d98ad8ebe316060a4 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 1 Nov 2025 17:20:39 +0100 Subject: [PATCH 173/192] migrate to this error it doesn't generate a bazillion warning at compile time --- Cargo.lock | 58 ++++++------------------ Cargo.toml | 3 +- src/api/admin/Cargo.toml | 2 +- src/api/admin/error.rs | 13 +++--- src/api/common/Cargo.toml | 2 +- src/api/common/common_error.rs | 30 ++++++------- src/api/common/signature/error.rs | 12 ++--- src/api/k2v/Cargo.toml | 2 +- src/api/k2v/error.rs | 24 +++++----- src/api/s3/Cargo.toml | 2 +- src/api/s3/error.rs | 46 ++++++++++--------- src/db/Cargo.toml | 2 +- src/db/lib.rs | 6 +-- src/model/Cargo.toml | 2 +- src/model/garage.rs | 6 +-- src/model/helper/error.rs | 14 +++--- src/net/Cargo.toml | 2 +- src/net/endpoint.rs | 4 +- src/net/error.rs | 44 +++++++++--------- src/rpc/Cargo.toml | 4 +- src/rpc/consul.rs | 16 +++---- src/util/Cargo.toml | 2 +- src/util/error.rs | 75 ++++++++++++++----------------- src/web/Cargo.toml | 2 +- src/web/error.rs | 8 ++-- 25 files changed, 172 insertions(+), 209 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c516bfbf..69bad7a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1099,20 +1099,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "err-derive" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34a887c8df3ed90498c1c437ce21f211c8e27672921a8ffa293cb8d6d4caa9e" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", - "synstructure 0.12.6", -] - [[package]] name = "errno" version = "0.3.10" @@ -1340,7 +1326,6 @@ version = "1.3.0" dependencies = [ "argon2", "async-trait", - "err-derive", "futures", "garage_api_common", "garage_model", @@ -1355,6 +1340,7 @@ dependencies = [ "prometheus", "serde", "serde_json", + "thiserror 2.0.12", "tokio", "tracing", "url", @@ -1370,7 +1356,6 @@ dependencies = [ "crc32c", "crc32fast", "crypto-common", - "err-derive", "futures", "garage_model", "garage_table", @@ -1389,6 +1374,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "thiserror 2.0.12", "tokio", "tracing", "url", @@ -1399,7 +1385,6 @@ name = "garage_api_k2v" version = "1.3.0" dependencies = [ "base64 0.21.7", - "err-derive", "futures", "garage_api_common", "garage_model", @@ -1412,6 +1397,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", + "thiserror 2.0.12", "tokio", "tracing", "url", @@ -1428,7 +1414,6 @@ dependencies = [ "chrono", "crc32c", "crc32fast", - "err-derive", "form_urlencoded", "futures", "garage_api_common", @@ -1455,6 +1440,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "thiserror 2.0.12", "tokio", "tokio-stream", "tokio-util 0.7.14", @@ -1491,7 +1477,6 @@ dependencies = [ name = "garage_db" version = "1.3.0" dependencies = [ - "err-derive", "fjall", "heed", "mktemp", @@ -1499,6 +1484,7 @@ dependencies = [ "r2d2", "r2d2_sqlite", "rusqlite", + "thiserror 2.0.12", "tracing", ] @@ -1510,7 +1496,6 @@ dependencies = [ "base64 0.21.7", "blake2", "chrono", - "err-derive", "futures", "garage_block", "garage_db", @@ -1524,6 +1509,7 @@ dependencies = [ "rand", "serde", "serde_bytes", + "thiserror 2.0.12", "tokio", "tracing", "zstd", @@ -1536,7 +1522,6 @@ dependencies = [ "arc-swap", "bytes", "cfg-if", - "err-derive", "futures", "hex", "kuska-handshake", @@ -1549,6 +1534,7 @@ dependencies = [ "rand", "rmp-serde", "serde", + "thiserror 2.0.12", "tokio", "tokio-stream", "tokio-util 0.7.14", @@ -1561,7 +1547,6 @@ dependencies = [ "arc-swap", "async-trait", "bytesize", - "err-derive", "format_table", "futures", "garage_net", @@ -1582,6 +1567,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "thiserror 2.0.12", "tokio", "tracing", ] @@ -1616,7 +1602,6 @@ dependencies = [ "blake2", "bytesize", "chrono", - "err-derive", "futures", "garage_db", "garage_net", @@ -1633,6 +1618,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "thiserror 2.0.12", "tokio", "toml", "tracing", @@ -1643,7 +1629,6 @@ dependencies = [ name = "garage_web" version = "1.3.0" dependencies = [ - "err-derive", "garage_api_common", "garage_api_s3", "garage_model", @@ -1654,6 +1639,7 @@ dependencies = [ "hyper 1.6.0", "opentelemetry", "percent-encoding", + "thiserror 2.0.12", "tokio", "tracing", ] @@ -2445,7 +2431,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tracing-subscriber", ] @@ -4233,18 +4219,6 @@ dependencies = [ "crossbeam-queue", ] -[[package]] -name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-xid", -] - [[package]] name = "synstructure" version = "0.13.1" @@ -4764,12 +4738,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -5272,7 +5240,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.100", - "synstructure 0.13.1", + "synstructure", ] [[package]] @@ -5333,7 +5301,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.100", - "synstructure 0.13.1", + "synstructure", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5bc76e3c..a21ac072 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,6 @@ chrono = "0.4" crc32fast = "1.4" crc32c = "0.6" crypto-common = "0.1" -err-derive = "0.3" gethostname = "0.4" git-version = "0.3.4" hex = "0.4" @@ -137,7 +136,7 @@ prometheus = "0.13" aws-sigv4 = { version = "1.1", default-features = false } hyper-rustls = { version = "0.26", default-features = false, features = ["http1", "http2", "ring", "rustls-native-certs"] } log = "0.4" -thiserror = "1.0" +thiserror = "2.0" # ---- used only as build / dev dependencies ---- assert-json-diff = "2.0" diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index d7184068..81735a85 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -22,7 +22,7 @@ garage_api_common.workspace = true argon2.workspace = true async-trait.workspace = true -err-derive.workspace = true +thiserror.workspace = true hex.workspace = true tracing.workspace = true diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index 201f9b40..97f02156 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -1,6 +1,6 @@ use std::convert::TryFrom; -use err_derive::Error; +use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; @@ -16,20 +16,17 @@ use garage_api_common::helpers::*; /// Errors of this crate #[derive(Debug, Error)] pub enum Error { - #[error(display = "{}", _0)] + #[error("{0}")] /// Error from common error - Common(#[error(source)] CommonError), + Common(#[from] CommonError), // Category: cannot process /// The API access key does not exist - #[error(display = "Access key not found: {}", _0)] + #[error("Access key not found: {0}")] NoSuchAccessKey(String), /// In Import key, the key already exists - #[error( - display = "Key {} already exists in data store. Even if it is deleted, we can't let you create a new key with the same ID. Sorry.", - _0 - )] + #[error("Key {0} already exists in data store. Even if it is deleted, we can't let you create a new key with the same ID. Sorry.")] KeyAlreadyExists(String), } diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index fd159c96..b337cd69 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -24,7 +24,7 @@ chrono.workspace = true crc32fast.workspace = true crc32c.workspace = true crypto-common.workspace = true -err-derive.workspace = true +thiserror.workspace = true hex.workspace = true hmac.workspace = true md-5.workspace = true diff --git a/src/api/common/common_error.rs b/src/api/common/common_error.rs index 597a3511..1335fece 100644 --- a/src/api/common/common_error.rs +++ b/src/api/common/common_error.rs @@ -1,6 +1,6 @@ use std::convert::TryFrom; -use err_derive::Error; +use thiserror::Error; use hyper::StatusCode; use garage_util::error::Error as GarageError; @@ -12,48 +12,48 @@ use garage_model::helper::error::Error as HelperError; pub enum CommonError { // ---- INTERNAL ERRORS ---- /// Error related to deeper parts of Garage - #[error(display = "Internal error: {}", _0)] - InternalError(#[error(source)] GarageError), + #[error("Internal error: {0}")] + InternalError(#[from] GarageError), /// Error related to Hyper - #[error(display = "Internal error (Hyper error): {}", _0)] - Hyper(#[error(source)] hyper::Error), + #[error("Internal error (Hyper error): {0}")] + Hyper(#[from] hyper::Error), /// Error related to HTTP - #[error(display = "Internal error (HTTP error): {}", _0)] - Http(#[error(source)] http::Error), + #[error("Internal error (HTTP error): {0}")] + Http(#[from] http::Error), // ---- GENERIC CLIENT ERRORS ---- /// Proper authentication was not provided - #[error(display = "Forbidden: {}", _0)] + #[error("Forbidden: {0}")] Forbidden(String), /// Generic bad request response with custom message - #[error(display = "Bad request: {}", _0)] + #[error("Bad request: {0}")] BadRequest(String), /// The client sent a header with invalid value - #[error(display = "Invalid header value: {}", _0)] - InvalidHeader(#[error(source)] hyper::header::ToStrError), + #[error("Invalid header value: {0}")] + InvalidHeader(#[from] hyper::header::ToStrError), // ---- SPECIFIC ERROR CONDITIONS ---- // These have to be error codes referenced in the S3 spec here: // https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html#ErrorCodeList /// The bucket requested don't exists - #[error(display = "Bucket not found: {}", _0)] + #[error("Bucket not found: {0}")] NoSuchBucket(String), /// Tried to create a bucket that already exist - #[error(display = "Bucket already exists")] + #[error("Bucket already exists")] BucketAlreadyExists, /// Tried to delete a non-empty bucket - #[error(display = "Tried to delete a non-empty bucket")] + #[error("Tried to delete a non-empty bucket")] BucketNotEmpty, // Category: bad request /// Bucket name is not valid according to AWS S3 specs - #[error(display = "Invalid bucket name: {}", _0)] + #[error("Invalid bucket name: {0}")] InvalidBucketName(String), } diff --git a/src/api/common/signature/error.rs b/src/api/common/signature/error.rs index b2f396b5..a1b353e1 100644 --- a/src/api/common/signature/error.rs +++ b/src/api/common/signature/error.rs @@ -1,4 +1,4 @@ -use err_derive::Error; +use thiserror::Error; use crate::common_error::CommonError; pub use crate::common_error::{CommonErrorDerivative, OkOrBadRequest, OkOrInternalError}; @@ -6,21 +6,21 @@ pub use crate::common_error::{CommonErrorDerivative, OkOrBadRequest, OkOrInterna /// Errors of this crate #[derive(Debug, Error)] pub enum Error { - #[error(display = "{}", _0)] + #[error("{0}")] /// Error from common error Common(CommonError), /// Authorization Header Malformed - #[error(display = "Authorization header malformed, unexpected scope: {}", _0)] + #[error("Authorization header malformed, unexpected scope: {0}")] AuthorizationHeaderMalformed(String), // Category: bad request /// The request contained an invalid UTF-8 sequence in its path or in other parameters - #[error(display = "Invalid UTF-8: {}", _0)] - InvalidUtf8Str(#[error(source)] std::str::Utf8Error), + #[error("Invalid UTF-8: {0}")] + InvalidUtf8Str(#[from] std::str::Utf8Error), /// The provided digest (checksum) value was invalid - #[error(display = "Invalid digest: {}", _0)] + #[error("Invalid digest: {0}")] InvalidDigest(String), } diff --git a/src/api/k2v/Cargo.toml b/src/api/k2v/Cargo.toml index 628d2db1..2b77f676 100644 --- a/src/api/k2v/Cargo.toml +++ b/src/api/k2v/Cargo.toml @@ -20,7 +20,7 @@ garage_util = { workspace = true, features = [ "k2v" ] } garage_api_common.workspace = true base64.workspace = true -err-derive.workspace = true +thiserror.workspace = true tracing.workspace = true futures.workspace = true diff --git a/src/api/k2v/error.rs b/src/api/k2v/error.rs index 257ff893..c860ab98 100644 --- a/src/api/k2v/error.rs +++ b/src/api/k2v/error.rs @@ -1,4 +1,4 @@ -use err_derive::Error; +use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; @@ -14,38 +14,38 @@ use garage_api_common::signature::error::Error as SignatureError; /// Errors of this crate #[derive(Debug, Error)] pub enum Error { - #[error(display = "{}", _0)] + #[error("{0}")] /// Error from common error - Common(#[error(source)] CommonError), + Common(#[from] CommonError), // Category: cannot process /// Authorization Header Malformed - #[error(display = "Authorization header malformed, unexpected scope: {}", _0)] + #[error("Authorization header malformed, unexpected scope: {0}")] AuthorizationHeaderMalformed(String), /// The provided digest (checksum) value was invalid - #[error(display = "Invalid digest: {}", _0)] + #[error("Invalid digest: {0}")] InvalidDigest(String), /// The object requested don't exists - #[error(display = "Key not found")] + #[error("Key not found")] NoSuchKey, /// Some base64 encoded data was badly encoded - #[error(display = "Invalid base64: {}", _0)] - InvalidBase64(#[error(source)] base64::DecodeError), + #[error("Invalid base64: {0}")] + InvalidBase64(#[from] base64::DecodeError), /// Invalid causality token - #[error(display = "Invalid causality token")] + #[error("Invalid causality token")] InvalidCausalityToken, /// The client asked for an invalid return format (invalid Accept header) - #[error(display = "Not acceptable: {}", _0)] + #[error("Not acceptable: {0}")] NotAcceptable(String), /// The request contained an invalid UTF-8 sequence in its path or in other parameters - #[error(display = "Invalid UTF-8: {}", _0)] - InvalidUtf8Str(#[error(source)] std::str::Utf8Error), + #[error("Invalid UTF-8: {0}")] + InvalidUtf8Str(#[from] std::str::Utf8Error), } commonErrorDerivative!(Error); diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 15f6858c..56f90864 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -29,7 +29,7 @@ bytes.workspace = true chrono.workspace = true crc32fast.workspace = true crc32c.workspace = true -err-derive.workspace = true +thiserror.workspace = true hex.workspace = true tracing.workspace = true md-5.workspace = true diff --git a/src/api/s3/error.rs b/src/api/s3/error.rs index 6d4b7a11..6f4dfb5c 100644 --- a/src/api/s3/error.rs +++ b/src/api/s3/error.rs @@ -1,6 +1,6 @@ use std::convert::TryInto; -use err_derive::Error; +use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; @@ -25,67 +25,67 @@ use crate::xml as s3_xml; /// Errors of this crate #[derive(Debug, Error)] pub enum Error { - #[error(display = "{}", _0)] + #[error("{0}")] /// Error from common error - Common(#[error(source)] CommonError), + Common(#[from] CommonError), // Category: cannot process /// Authorization Header Malformed - #[error(display = "Authorization header malformed, unexpected scope: {}", _0)] + #[error("Authorization header malformed, unexpected scope: {0}")] AuthorizationHeaderMalformed(String), /// The object requested don't exists - #[error(display = "Key not found")] + #[error("Key not found")] NoSuchKey, /// The multipart upload requested don't exists - #[error(display = "Upload not found")] + #[error("Upload not found")] NoSuchUpload, /// Precondition failed (e.g. x-amz-copy-source-if-match) - #[error(display = "At least one of the preconditions you specified did not hold")] + #[error("At least one of the preconditions you specified did not hold")] PreconditionFailed, /// Parts specified in CMU request do not match parts actually uploaded - #[error(display = "Parts given to CompleteMultipartUpload do not match uploaded parts")] + #[error("Parts given to CompleteMultipartUpload do not match uploaded parts")] InvalidPart, /// Parts given to CompleteMultipartUpload were not in ascending order - #[error(display = "Parts given to CompleteMultipartUpload were not in ascending order")] + #[error("Parts given to CompleteMultipartUpload were not in ascending order")] InvalidPartOrder, /// In CompleteMultipartUpload: not enough data /// (here we are more lenient than AWS S3) - #[error(display = "Proposed upload is smaller than the minimum allowed object size")] + #[error("Proposed upload is smaller than the minimum allowed object size")] EntityTooSmall, // Category: bad request /// The request contained an invalid UTF-8 sequence in its path or in other parameters - #[error(display = "Invalid UTF-8: {}", _0)] - InvalidUtf8Str(#[error(source)] std::str::Utf8Error), + #[error("Invalid UTF-8: {0}")] + InvalidUtf8Str(#[from] std::str::Utf8Error), /// The request used an invalid path - #[error(display = "Invalid UTF-8: {}", _0)] - InvalidUtf8String(#[error(source)] std::string::FromUtf8Error), + #[error("Invalid UTF-8: {0}")] + InvalidUtf8String(#[from] std::string::FromUtf8Error), /// The client sent invalid XML data - #[error(display = "Invalid XML: {}", _0)] + #[error("Invalid XML: {0}")] InvalidXml(String), /// The client sent a range header with invalid value - #[error(display = "Invalid HTTP range: {:?}", _0)] - InvalidRange(#[error(from)] (http_range::HttpRangeParseError, u64)), + #[error("Invalid HTTP range: {0:?}")] + InvalidRange((http_range::HttpRangeParseError, u64)), /// The client sent a range header with invalid value - #[error(display = "Invalid encryption algorithm: {:?}, should be AES256", _0)] + #[error("Invalid encryption algorithm: {0:?}, should be AES256")] InvalidEncryptionAlgorithm(String), /// The provided digest (checksum) value was invalid - #[error(display = "Invalid digest: {}", _0)] + #[error("Invalid digest: {0}")] InvalidDigest(String), /// The client sent a request for an action not supported by garage - #[error(display = "Unimplemented action: {}", _0)] + #[error("Unimplemented action: {0}")] NotImplemented(String), } @@ -99,6 +99,12 @@ impl From for Error { } } +impl From<(http_range::HttpRangeParseError, u64)> for Error { + fn from (err: (http_range::HttpRangeParseError, u64)) -> Error { + Error::InvalidRange(err) + } +} + impl From for Error { fn from(err: roxmltree::Error) -> Self { Self::InvalidXml(format!("{}", err)) diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index 6dee2fa6..7c1c8d90 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -12,7 +12,7 @@ readme = "../../README.md" path = "lib.rs" [dependencies] -err-derive.workspace = true +thiserror.workspace = true tracing.workspace = true heed = { workspace = true, optional = true } diff --git a/src/db/lib.rs b/src/db/lib.rs index 71826255..2a467c7c 100644 --- a/src/db/lib.rs +++ b/src/db/lib.rs @@ -20,7 +20,7 @@ use std::cell::Cell; use std::path::PathBuf; use std::sync::Arc; -use err_derive::Error; +use thiserror::Error; pub use open::*; @@ -44,7 +44,7 @@ pub type TxValueIter<'a> = Box); impl From for Error { @@ -56,7 +56,7 @@ impl From for Error { pub type Result = std::result::Result; #[derive(Debug, Error)] -#[error(display = "{}", _0)] +#[error("{0}")] pub struct TxOpError(pub(crate) Error); pub type TxOpResult = std::result::Result; diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index e59765d7..579092d2 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -24,7 +24,7 @@ garage_net.workspace = true async-trait.workspace = true blake2.workspace = true chrono.workspace = true -err-derive.workspace = true +thiserror.workspace = true hex.workspace = true http.workspace = true base64.workspace = true diff --git a/src/model/garage.rs b/src/model/garage.rs index 38f8f1f7..f4f6f693 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -315,15 +315,15 @@ impl Garage { Ok(()) } - pub fn bucket_helper(&self) -> helper::bucket::BucketHelper { + pub fn bucket_helper(&self) -> helper::bucket::BucketHelper<'_> { helper::bucket::BucketHelper(self) } - pub fn key_helper(&self) -> helper::key::KeyHelper { + pub fn key_helper(&self) -> helper::key::KeyHelper<'_> { helper::key::KeyHelper(self) } - pub async fn locked_helper(&self) -> helper::locked::LockedHelper { + pub async fn locked_helper(&self) -> helper::locked::LockedHelper<'_> { let lock = self.bucket_lock.lock().await; helper::locked::LockedHelper(self, Some(lock)) } diff --git a/src/model/helper/error.rs b/src/model/helper/error.rs index e2ffdd68..6a78546d 100644 --- a/src/model/helper/error.rs +++ b/src/model/helper/error.rs @@ -1,24 +1,24 @@ -use err_derive::Error; +use thiserror::Error; use serde::{Deserialize, Serialize}; use garage_util::error::Error as GarageError; #[derive(Debug, Error, Serialize, Deserialize)] pub enum Error { - #[error(display = "Internal error: {}", _0)] - Internal(#[error(source)] GarageError), + #[error("Internal error: {0}")] + Internal(#[from] GarageError), - #[error(display = "Bad request: {}", _0)] + #[error("Bad request: {0}")] BadRequest(String), /// Bucket name is not valid according to AWS S3 specs - #[error(display = "Invalid bucket name: {}", _0)] + #[error("Invalid bucket name: {0}")] InvalidBucketName(String), - #[error(display = "Access key not found: {}", _0)] + #[error("Access key not found: {0}")] NoSuchAccessKey(String), - #[error(display = "Bucket not found: {}", _0)] + #[error("Bucket not found: {0}")] NoSuchBucket(String), } diff --git a/src/net/Cargo.toml b/src/net/Cargo.toml index 83b3b15b..8ff78680 100644 --- a/src/net/Cargo.toml +++ b/src/net/Cargo.toml @@ -30,7 +30,7 @@ rand.workspace = true log.workspace = true arc-swap.workspace = true -err-derive.workspace = true +thiserror.workspace = true bytes.workspace = true cfg-if.workspace = true diff --git a/src/net/endpoint.rs b/src/net/endpoint.rs index d46acc42..3ab1048a 100644 --- a/src/net/endpoint.rs +++ b/src/net/endpoint.rs @@ -159,7 +159,7 @@ where pub(crate) type DynEndpoint = Box; pub(crate) trait GenericEndpoint { - fn handle(&self, req_enc: ReqEnc, from: NodeID) -> BoxFuture>; + fn handle(&self, req_enc: ReqEnc, from: NodeID) -> BoxFuture<'_, Result>; fn drop_handler(&self); fn clone_endpoint(&self) -> DynEndpoint; } @@ -175,7 +175,7 @@ where M: Message, H: StreamingEndpointHandler + 'static, { - fn handle(&self, req_enc: ReqEnc, from: NodeID) -> BoxFuture> { + fn handle(&self, req_enc: ReqEnc, from: NodeID) -> BoxFuture<'_, Result> { async move { match self.0.handler.load_full() { None => Err(Error::NoHandler), diff --git a/src/net/error.rs b/src/net/error.rs index cddb1eaa..899fe21c 100644 --- a/src/net/error.rs +++ b/src/net/error.rs @@ -1,49 +1,49 @@ use std::io; -use err_derive::Error; +use thiserror::Error; use log::error; #[derive(Debug, Error)] pub enum Error { - #[error(display = "IO error: {}", _0)] - Io(#[error(source)] io::Error), + #[error("IO error: {0}")] + Io(#[from] io::Error), - #[error(display = "Messagepack encode error: {}", _0)] - RMPEncode(#[error(source)] rmp_serde::encode::Error), - #[error(display = "Messagepack decode error: {}", _0)] - RMPDecode(#[error(source)] rmp_serde::decode::Error), + #[error("Messagepack encode error: {0}")] + RMPEncode(#[from] rmp_serde::encode::Error), + #[error("Messagepack decode error: {0}")] + RMPDecode(#[from] rmp_serde::decode::Error), - #[error(display = "Tokio join error: {}", _0)] - TokioJoin(#[error(source)] tokio::task::JoinError), + #[error("Tokio join error: {0}")] + TokioJoin(#[from] tokio::task::JoinError), - #[error(display = "oneshot receive error: {}", _0)] - OneshotRecv(#[error(source)] tokio::sync::oneshot::error::RecvError), + #[error("oneshot receive error: {0}")] + OneshotRecv(#[from] tokio::sync::oneshot::error::RecvError), - #[error(display = "Handshake error: {}", _0)] - Handshake(#[error(source)] kuska_handshake::async_std::Error), + #[error("Handshake error: {0}")] + Handshake(#[from] kuska_handshake::async_std::Error), - #[error(display = "UTF8 error: {}", _0)] - UTF8(#[error(source)] std::string::FromUtf8Error), + #[error("UTF8 error: {0}")] + UTF8(#[from] std::string::FromUtf8Error), - #[error(display = "Framing protocol error")] + #[error("Framing protocol error")] Framing, - #[error(display = "Remote error ({:?}): {}", _0, _1)] + #[error("Remote error ({0:?}): {1}")] Remote(io::ErrorKind, String), - #[error(display = "Request ID collision")] + #[error("Request ID collision")] IdCollision, - #[error(display = "{}", _0)] + #[error("{0}")] Message(String), - #[error(display = "No handler / shutting down")] + #[error("No handler / shutting down")] NoHandler, - #[error(display = "Connection closed")] + #[error("Connection closed")] ConnectionClosed, - #[error(display = "Version mismatch: {}", _0)] + #[error("Version mismatch: {0}")] VersionMismatch(String), } diff --git a/src/rpc/Cargo.toml b/src/rpc/Cargo.toml index 1e764c77..9e886748 100644 --- a/src/rpc/Cargo.toml +++ b/src/rpc/Cargo.toml @@ -33,7 +33,7 @@ async-trait.workspace = true serde.workspace = true serde_bytes.workspace = true serde_json.workspace = true -err-derive = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } # newer version requires rust edition 2021 kube = { workspace = true, optional = true } @@ -49,5 +49,5 @@ opentelemetry.workspace = true [features] kubernetes-discovery = [ "kube", "k8s-openapi", "schemars" ] -consul-discovery = [ "reqwest", "err-derive" ] +consul-discovery = [ "reqwest", "thiserror" ] system-libs = [ "sodiumoxide/use-pkg-config" ] diff --git a/src/rpc/consul.rs b/src/rpc/consul.rs index f088bf3f..801e937f 100644 --- a/src/rpc/consul.rs +++ b/src/rpc/consul.rs @@ -3,7 +3,7 @@ use std::fs::File; use std::io::Read; use std::net::{IpAddr, SocketAddr}; -use err_derive::Error; +use thiserror::Error; use serde::{Deserialize, Serialize}; use garage_net::NodeID; @@ -219,12 +219,12 @@ impl ConsulDiscovery { /// Regroup all Consul discovery errors #[derive(Debug, Error)] pub enum ConsulError { - #[error(display = "IO error: {}", _0)] - Io(#[error(source)] std::io::Error), - #[error(display = "HTTP error: {}", _0)] - Reqwest(#[error(source)] reqwest::Error), - #[error(display = "Invalid Consul TLS configuration")] + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("HTTP error: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("Invalid Consul TLS configuration")] InvalidTLSConfig, - #[error(display = "Token error: {}", _0)] - Token(#[error(source)] reqwest::header::InvalidHeaderValue), + #[error("Token error: {0}")] + Token(#[from] reqwest::header::InvalidHeaderValue), } diff --git a/src/util/Cargo.toml b/src/util/Cargo.toml index 0d693a97..b5c1454f 100644 --- a/src/util/Cargo.toml +++ b/src/util/Cargo.toml @@ -21,7 +21,7 @@ arc-swap.workspace = true async-trait.workspace = true blake2.workspace = true bytesize.workspace = true -err-derive.workspace = true +thiserror.workspace = true hexdump.workspace = true xxhash-rust.workspace = true hex.workspace = true diff --git a/src/util/error.rs b/src/util/error.rs index 75fd3f9c..170d2687 100644 --- a/src/util/error.rs +++ b/src/util/error.rs @@ -2,7 +2,7 @@ use std::fmt; use std::io; -use err_derive::Error; +use thiserror::Error; use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; @@ -12,68 +12,61 @@ use crate::encode::debug_serialize; /// Regroup all Garage errors #[derive(Debug, Error)] pub enum Error { - #[error(display = "IO error: {}", _0)] - Io(#[error(source)] io::Error), + #[error("IO error: {0}")] + Io(#[from] io::Error), - #[error(display = "Hyper error: {}", _0)] - Hyper(#[error(source)] hyper::Error), + #[error("Hyper error: {0}")] + Hyper(#[from] hyper::Error), - #[error(display = "HTTP error: {}", _0)] - Http(#[error(source)] http::Error), + #[error("HTTP error: {0}")] + Http(#[from] http::Error), - #[error(display = "Invalid HTTP header value: {}", _0)] - HttpHeader(#[error(source)] http::header::ToStrError), + #[error("Invalid HTTP header value: {0}")] + HttpHeader(#[from] http::header::ToStrError), - #[error(display = "Network error: {}", _0)] - Net(#[error(source)] garage_net::error::Error), + #[error("Network error: {0}")] + Net(#[from] garage_net::error::Error), - #[error(display = "DB error: {}", _0)] - Db(#[error(source)] garage_db::Error), + #[error("DB error: {0}")] + Db(#[from] garage_db::Error), - #[error(display = "Messagepack encode error: {}", _0)] - RmpEncode(#[error(source)] rmp_serde::encode::Error), - #[error(display = "Messagepack decode error: {}", _0)] - RmpDecode(#[error(source)] rmp_serde::decode::Error), - #[error(display = "JSON error: {}", _0)] - Json(#[error(source)] serde_json::error::Error), - #[error(display = "TOML decode error: {}", _0)] - TomlDecode(#[error(source)] toml::de::Error), + #[error("Messagepack encode error: {0}")] + RmpEncode(#[from] rmp_serde::encode::Error), + #[error("Messagepack decode error: {0}")] + RmpDecode(#[from] rmp_serde::decode::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::error::Error), + #[error("TOML decode error: {0}")] + TomlDecode(#[from] toml::de::Error), - #[error(display = "Tokio join error: {}", _0)] - TokioJoin(#[error(source)] tokio::task::JoinError), + #[error("Tokio join error: {0}")] + TokioJoin(#[from] tokio::task::JoinError), - #[error(display = "Tokio semaphore acquire error: {}", _0)] - TokioSemAcquire(#[error(source)] tokio::sync::AcquireError), + #[error("Tokio semaphore acquire error: {0}")] + TokioSemAcquire(#[from] tokio::sync::AcquireError), - #[error(display = "Tokio broadcast receive error: {}", _0)] - TokioBcastRecv(#[error(source)] tokio::sync::broadcast::error::RecvError), + #[error("Tokio broadcast receive error: {0}")] + TokioBcastRecv(#[from] tokio::sync::broadcast::error::RecvError), - #[error(display = "Remote error: {}", _0)] + #[error("Remote error: {0}")] RemoteError(String), - #[error(display = "Timeout")] + #[error("Timeout")] Timeout, - #[error( - display = "Could not reach quorum of {} (sets={:?}). {} of {} request succeeded, others returned errors: {:?}", - _0, - _1, - _2, - _3, - _4 - )] + #[error("Could not reach quorum of {0} (sets={1:?}). {2} of {3} request succeeded, others returned errors: {4:?}")] Quorum(usize, Option, usize, usize, Vec), - #[error(display = "Unexpected RPC message: {}", _0)] + #[error("Unexpected RPC message: {0}")] UnexpectedRpcMessage(String), - #[error(display = "Corrupt data: does not match hash {:?}", _0)] + #[error("Corrupt data: does not match hash {0:?}")] CorruptData(Hash), - #[error(display = "Missing block {:?}: no node returned a valid block", _0)] + #[error("Missing block {0:?}: no node returned a valid block")] MissingBlock(Hash), - #[error(display = "{}", _0)] + #[error("{0}")] Message(String), } diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index c1056509..a2daf84d 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -20,7 +20,7 @@ garage_model.workspace = true garage_util.workspace = true garage_table.workspace = true -err-derive.workspace = true +thiserror.workspace = true tracing.workspace = true percent-encoding.workspace = true diff --git a/src/web/error.rs b/src/web/error.rs index 7e6d4542..49650b1d 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -1,4 +1,4 @@ -use err_derive::Error; +use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; @@ -8,15 +8,15 @@ use garage_api_common::generic_server::ApiError; #[derive(Debug, Error)] pub enum Error { /// An error received from the API crate - #[error(display = "API error: {}", _0)] + #[error("API error: {0}")] ApiError(garage_api_s3::error::Error), /// The file does not exist - #[error(display = "Not found")] + #[error("Not found")] NotFound, /// The client sent a request without host, or with unsupported method - #[error(display = "Bad request: {}", _0)] + #[error("Bad request: {0}")] BadRequest(String), } From ac851d6dee762576415541cf1b6eb5345d03ea9b Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 1 Nov 2025 18:04:54 +0100 Subject: [PATCH 174/192] fmt --- src/api/admin/error.rs | 2 +- src/api/common/common_error.rs | 2 +- src/api/k2v/error.rs | 2 +- src/api/s3/error.rs | 8 ++++---- src/model/helper/error.rs | 2 +- src/net/error.rs | 2 +- src/rpc/consul.rs | 2 +- src/web/error.rs | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index 97f02156..17d4c200 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -1,8 +1,8 @@ use std::convert::TryFrom; -use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; +use thiserror::Error; pub use garage_model::helper::error::Error as HelperError; diff --git a/src/api/common/common_error.rs b/src/api/common/common_error.rs index 1335fece..e596a6e9 100644 --- a/src/api/common/common_error.rs +++ b/src/api/common/common_error.rs @@ -1,7 +1,7 @@ use std::convert::TryFrom; -use thiserror::Error; use hyper::StatusCode; +use thiserror::Error; use garage_util::error::Error as GarageError; diff --git a/src/api/k2v/error.rs b/src/api/k2v/error.rs index c860ab98..f1937fe5 100644 --- a/src/api/k2v/error.rs +++ b/src/api/k2v/error.rs @@ -1,6 +1,6 @@ -use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; +use thiserror::Error; use garage_api_common::common_error::{commonErrorDerivative, CommonError}; pub(crate) use garage_api_common::common_error::{helper_error_as_internal, pass_helper_error}; diff --git a/src/api/s3/error.rs b/src/api/s3/error.rs index 6f4dfb5c..64112084 100644 --- a/src/api/s3/error.rs +++ b/src/api/s3/error.rs @@ -1,8 +1,8 @@ use std::convert::TryInto; -use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; +use thiserror::Error; use garage_model::helper::error::Error as HelperError; @@ -100,9 +100,9 @@ impl From for Error { } impl From<(http_range::HttpRangeParseError, u64)> for Error { - fn from (err: (http_range::HttpRangeParseError, u64)) -> Error { - Error::InvalidRange(err) - } + fn from(err: (http_range::HttpRangeParseError, u64)) -> Error { + Error::InvalidRange(err) + } } impl From for Error { diff --git a/src/model/helper/error.rs b/src/model/helper/error.rs index 6a78546d..bc483c7d 100644 --- a/src/model/helper/error.rs +++ b/src/model/helper/error.rs @@ -1,5 +1,5 @@ -use thiserror::Error; use serde::{Deserialize, Serialize}; +use thiserror::Error; use garage_util::error::Error as GarageError; diff --git a/src/net/error.rs b/src/net/error.rs index 899fe21c..f67794ed 100644 --- a/src/net/error.rs +++ b/src/net/error.rs @@ -1,7 +1,7 @@ use std::io; -use thiserror::Error; use log::error; +use thiserror::Error; #[derive(Debug, Error)] pub enum Error { diff --git a/src/rpc/consul.rs b/src/rpc/consul.rs index 801e937f..760e9fcb 100644 --- a/src/rpc/consul.rs +++ b/src/rpc/consul.rs @@ -3,8 +3,8 @@ use std::fs::File; use std::io::Read; use std::net::{IpAddr, SocketAddr}; -use thiserror::Error; use serde::{Deserialize, Serialize}; +use thiserror::Error; use garage_net::NodeID; diff --git a/src/web/error.rs b/src/web/error.rs index 49650b1d..aef74923 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -1,6 +1,6 @@ -use thiserror::Error; use hyper::header::HeaderValue; use hyper::{HeaderMap, StatusCode}; +use thiserror::Error; use garage_api_common::generic_server::ApiError; From a057ab23ea19221e9c646bc55092fe7c20648e80 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 24 Nov 2025 11:09:46 +0100 Subject: [PATCH 175/192] Update rust toolchain --- flake.lock | 16 ++++++++-------- flake.nix | 8 ++++---- nix/compile.nix | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/flake.lock b/flake.lock index 2cfbfda4..211b70e0 100644 --- a/flake.lock +++ b/flake.lock @@ -50,17 +50,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 1736692550, - "narHash": "sha256-7tk8xH+g0sJkKLTJFOxphJxxOjMDFMWv24nXslaU2ro=", + "lastModified": 1763977559, + "narHash": "sha256-g4MKqsIRy5yJwEsI+fYODqLUnAqIY4kZai0nldAP6EM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7c4869c47090dd7f9f1bdfb49a22aea026996815", + "rev": "cfe2c7d5b5d3032862254e68c37a6576b633d632", "type": "github" }, "original": { "owner": "NixOS", "repo": "nixpkgs", - "rev": "7c4869c47090dd7f9f1bdfb49a22aea026996815", + "rev": "cfe2c7d5b5d3032862254e68c37a6576b633d632", "type": "github" } }, @@ -80,17 +80,17 @@ ] }, "locked": { - "lastModified": 1738549608, - "narHash": "sha256-GdyT9QEUSx5k/n8kILuNy83vxxdyUfJ8jL5mMpQZWfw=", + "lastModified": 1763952169, + "narHash": "sha256-+PeDBD8P+NKauH+w7eO/QWCIp8Cx4mCfWnh9sJmy9CM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "35c6f8c4352f995ecd53896200769f80a3e8f22d", + "rev": "ab726555a9a72e6dc80649809147823a813fa95b", "type": "github" }, "original": { "owner": "oxalica", "repo": "rust-overlay", - "rev": "35c6f8c4352f995ecd53896200769f80a3e8f22d", + "rev": "ab726555a9a72e6dc80649809147823a813fa95b", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 2fb8c48e..48880347 100644 --- a/flake.nix +++ b/flake.nix @@ -2,13 +2,13 @@ description = "Garage, an S3-compatible distributed object store for self-hosted deployments"; - # Nixpkgs 24.11 as of 2025-01-12 + # Nixpkgs 25.05 as of 2025-11-24 inputs.nixpkgs.url = - "github:NixOS/nixpkgs/7c4869c47090dd7f9f1bdfb49a22aea026996815"; + "github:NixOS/nixpkgs/cfe2c7d5b5d3032862254e68c37a6576b633d632"; - # Rust overlay as of 2025-02-03 + # Rust overlay as of 2025-11-24 inputs.rust-overlay.url = - "github:oxalica/rust-overlay/35c6f8c4352f995ecd53896200769f80a3e8f22d"; + "github:oxalica/rust-overlay/ab726555a9a72e6dc80649809147823a813fa95b"; inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; inputs.crane.url = "github:ipetkov/crane"; diff --git a/nix/compile.nix b/nix/compile.nix index 7e9f79ab..5a49526f 100644 --- a/nix/compile.nix +++ b/nix/compile.nix @@ -48,7 +48,7 @@ let inherit (pkgs) lib stdenv; - toolchainFn = (p: p.rust-bin.stable."1.82.0".default.override { + toolchainFn = (p: p.rust-bin.stable."1.91.0".default.override { targets = lib.optionals (target != null) [ rustTarget ]; extensions = [ "rust-src" From ca3b4a050d25cbfe774cb0db14aefdc61a1f2446 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 24 Nov 2025 17:03:02 +0100 Subject: [PATCH 176/192] update nixos image used in woodpecker ci --- .woodpecker/debug.yaml | 12 ++++++------ .woodpecker/publish.yaml | 4 ++-- .woodpecker/release.yaml | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.woodpecker/debug.yaml b/.woodpecker/debug.yaml index 4c729672..0f60b4e3 100644 --- a/.woodpecker/debug.yaml +++ b/.woodpecker/debug.yaml @@ -12,32 +12,32 @@ when: steps: - name: check formatting - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-shell --attr devShell --run "cargo fmt -- --check" - name: build - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-build -j4 --attr flakePackages.dev - name: unit + func tests (lmdb) - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-build -j4 --attr flakePackages.tests-lmdb - name: unit + func tests (sqlite) - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-build -j4 --attr flakePackages.tests-sqlite - name: unit + func tests (fjall) - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-build -j4 --attr flakePackages.tests-fjall - name: integration tests - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-build -j4 --attr flakePackages.dev - nix-shell --attr ci --run ./script/test-smoke.sh || (cat /tmp/garage.log; false) diff --git a/.woodpecker/publish.yaml b/.woodpecker/publish.yaml index 24a84463..8f3b482f 100644 --- a/.woodpecker/publish.yaml +++ b/.woodpecker/publish.yaml @@ -11,7 +11,7 @@ depends_on: steps: - name: refresh-index - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 environment: AWS_ACCESS_KEY_ID: from_secret: garagehq_aws_access_key_id @@ -22,7 +22,7 @@ steps: - nix-shell --attr ci --run "refresh_index" - name: multiarch-docker - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 environment: DOCKER_AUTH: from_secret: docker_auth diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index bf2bd8ba..a94a9ccf 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -19,17 +19,17 @@ matrix: steps: - name: build - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-build --attr releasePackages.${ARCH} --argstr git_version ${CI_COMMIT_TAG:-$CI_COMMIT_SHA} - name: check is static binary - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-shell --attr ci --run "./script/not-dynamic.sh result/bin/garage" - name: integration tests - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-shell --attr ci --run ./script/test-smoke.sh || (cat /tmp/garage.log; false) when: @@ -39,7 +39,7 @@ steps: ARCH: i386 - name: upgrade tests - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 commands: - nix-shell --attr ci --run "./script/test-upgrade.sh v0.8.4 x86_64-unknown-linux-musl" || (cat /tmp/garage.log; false) when: @@ -47,7 +47,7 @@ steps: ARCH: amd64 - name: push static binary - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 environment: TARGET: "${TARGET}" AWS_ACCESS_KEY_ID: @@ -58,7 +58,7 @@ steps: - nix-shell --attr ci --run "to_s3" - name: docker build and publish - image: nixpkgs/nix:nixos-22.05 + image: nixpkgs/nix:nixos-24.05 environment: DOCKER_PLATFORM: "linux/${ARCH}" CONTAINER_NAME: "dxflrs/${ARCH}_garage" From ca296477f3adc024b6606712c4f47b8ef877868f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 24 Nov 2025 17:56:28 +0100 Subject: [PATCH 177/192] disable checksums in aws cli (todo: revert in main-v2) --- script/dev-env-aws.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/script/dev-env-aws.sh b/script/dev-env-aws.sh index 808f9cf1..41f1fdde 100644 --- a/script/dev-env-aws.sh +++ b/script/dev-env-aws.sh @@ -1,6 +1,7 @@ export AWS_ACCESS_KEY_ID=`cat /tmp/garage.s3 |cut -d' ' -f1` export AWS_SECRET_ACCESS_KEY=`cat /tmp/garage.s3 |cut -d' ' -f2` export AWS_DEFAULT_REGION='garage' +export AWS_REQUEST_CHECKSUM_CALCULATION='when_required' # FUTUREWORK: set AWS_ENDPOINT_URL instead, once nixpkgs bumps awscli to >=2.13.0. function aws { command aws --endpoint-url http://127.0.0.1:3911 $@ ; } From 95693d45b20c08122c9b9cdcb259f9c70d233522 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 24 Nov 2025 18:09:53 +0100 Subject: [PATCH 178/192] run cargo fmt as a nix derivation --- .woodpecker/debug.yaml | 2 +- flake.nix | 8 ++++++++ nix/compile.nix | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.woodpecker/debug.yaml b/.woodpecker/debug.yaml index 0f60b4e3..4dc7d3c9 100644 --- a/.woodpecker/debug.yaml +++ b/.woodpecker/debug.yaml @@ -14,7 +14,7 @@ steps: - name: check formatting image: nixpkgs/nix:nixos-24.05 commands: - - nix-shell --attr devShell --run "cargo fmt -- --check" + - nix-build -j4 --attr flakePackages.fmt - name: build image: nixpkgs/nix:nixos-24.05 diff --git a/flake.nix b/flake.nix index 48880347..01a077c4 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,10 @@ inherit system nixpkgs crane rust-overlay extraTestEnv; release = false; }).garage-test; + lints = (compile { + inherit system nixpkgs crane rust-overlay; + release = false; + }); in { packages = { @@ -56,6 +60,10 @@ tests-fjall = testWith { GARAGE_TEST_INTEGRATION_DB_ENGINE = "fjall"; }; + + # lints (fmt, clippy) + fmt = lints.garage-cargo-fmt; + clippy = lints.garage-cargo-clippy; }; # ---- developpment shell, for making native builds only ---- diff --git a/nix/compile.nix b/nix/compile.nix index 5a49526f..c6df9dbd 100644 --- a/nix/compile.nix +++ b/nix/compile.nix @@ -190,4 +190,15 @@ in rec { pkgs.cacert ]; } // extraTestEnv); + + # ---- source code linting ---- + + garage-cargo-fmt = craneLib.cargoFmt (commonArgs // { + cargoExtraArgs = ""; + }); + + garage-cargo-clippy = craneLib.cargoClippy (commonArgs // { + cargoArtifacts = garage-deps; + cargoClippyExtraArgs = "--all-targets -- -D warnings"; + }); } From 511cf0c6ec3d4ab6cd5a40cd0be299765e15671e Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 24 Nov 2025 18:37:34 +0100 Subject: [PATCH 179/192] disable awscli checksumming in ci scripts required because garage.deuxfleurs.fr is still running v1.x --- shell.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shell.nix b/shell.nix index cfccfe94..c3dedca8 100644 --- a/shell.nix +++ b/shell.nix @@ -34,6 +34,8 @@ in jq ]; shellHook = '' + export AWS_REQUEST_CHECKSUM_CALCULATION='when_required' + function to_s3 { aws \ --endpoint-url https://garage.deuxfleurs.fr \ From 4d124e1c76b1ffdd9e1944500c0587194a9aa05d Mon Sep 17 00:00:00 2001 From: "perrynzhou@gmail.com" Date: Wed, 10 Dec 2025 06:43:51 +0800 Subject: [PATCH 180/192] Add the parameter, which replaces . This is to accommodate different storage media such as HDD and NVMe. --- src/api/s3/put.rs | 4 +--- src/util/config.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/api/s3/put.rs b/src/api/s3/put.rs index 830a7998..5f8845e7 100644 --- a/src/api/s3/put.rs +++ b/src/api/s3/put.rs @@ -39,8 +39,6 @@ use crate::encryption::EncryptionParams; use crate::error::*; use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION; -const PUT_BLOCKS_MAX_PARALLEL: usize = 3; - pub(crate) struct SaveStreamResult { pub(crate) version_uuid: Uuid, pub(crate) version_timestamp: u64, @@ -493,7 +491,7 @@ pub(crate) async fn read_and_put_blocks> + }; let recv_next = async { // If more than a maximum number of writes are in progress, don't add more for now - if currently_running >= PUT_BLOCKS_MAX_PARALLEL { + if currently_running >= ctx.garage.config.put_blocks_max_parallel { futures::future::pending().await } else { block_rx3.recv().await diff --git a/src/util/config.rs b/src/util/config.rs index e351185f..76b40aa9 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -45,6 +45,11 @@ pub struct Config { )] pub block_size: usize, + /// Maximum number of parallel block writes per PUT request + /// Higher values improve throughput but increase memory usage + /// Default: 3, Recommended: 10-30 for NVMe, 3-10 for HDD + #[serde(default = "default_put_blocks_max_parallel")] + pub put_blocks_max_parallel: usize, /// Number of replicas. Can be any positive integer, but uneven numbers are more favorable. /// - 1 for single-node clusters, or to disable replication /// - 3 is the recommended and supported setting. @@ -267,6 +272,9 @@ pub struct KubernetesDiscoveryConfig { pub skip_crd: bool, } +pub fn default_put_blocks_max_parallel() -> usize { + 3 +} /// Read and parse configuration pub fn read_config(config_file: PathBuf) -> Result { let config = std::fs::read_to_string(config_file)?; From e3a5ec6ef6ab1cc4741e1e26f10aa6cde591a214 Mon Sep 17 00:00:00 2001 From: "perrynzhou@gmail.com" Date: Fri, 12 Dec 2025 07:09:38 +0800 Subject: [PATCH 181/192] rename put_blocks_max_parallel to block_max_concurrent_writes_per_request and update configuration.md --- doc/book/reference-manual/configuration.md | 11 ++++++++++- src/api/s3/put.rs | 2 +- src/util/config.rs | 6 +++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index c6dce089..1f583fe6 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -25,7 +25,7 @@ db_engine = "lmdb" block_size = "1M" block_ram_buffer_max = "256MiB" block_max_concurrent_reads = 16 - +block_max_concurrent_writes_per_request =10 lmdb_map_size = "1T" compression_level = 1 @@ -99,6 +99,7 @@ Top-level configuration options, in alphabetical order: [`allow_world_readable_secrets`](#allow_world_readable_secrets), [`block_max_concurrent_reads`](`block_max_concurrent_reads), [`block_ram_buffer_max`](#block_ram_buffer_max), +[`block_max_concurrent_writes_per_request`](#block_max_concurrent_writes_per_request), [`block_size`](#block_size), [`bootstrap_peers`](#bootstrap_peers), [`compression_level`](#compression_level), @@ -547,6 +548,14 @@ metric in Prometheus: a non-zero number of such events indicates an I/O bottleneck on HDD read speed. +#### `block_max_concurrent_writes_per_request` (since `v2.1.0`) {#block_max_concurrent_writes_per_request} + +This parameter is designed to adapt to the concurrent write performance of +different storage media.Maximum number of parallel block writes per put request +Higher values improve throughput but increase memory usage. + +Default: 3, Recommended: 10-30 for NVMe, 3-10 for HDD + #### `lmdb_map_size` {#lmdb_map_size} This parameters can be used to set the map size used by LMDB, diff --git a/src/api/s3/put.rs b/src/api/s3/put.rs index 5f8845e7..b915f2ec 100644 --- a/src/api/s3/put.rs +++ b/src/api/s3/put.rs @@ -491,7 +491,7 @@ pub(crate) async fn read_and_put_blocks> + }; let recv_next = async { // If more than a maximum number of writes are in progress, don't add more for now - if currently_running >= ctx.garage.config.put_blocks_max_parallel { + if currently_running >= ctx.garage.config.block_max_concurrent_writes_per_request { futures::future::pending().await } else { block_rx3.recv().await diff --git a/src/util/config.rs b/src/util/config.rs index 76b40aa9..eb889ebe 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -48,8 +48,8 @@ pub struct Config { /// Maximum number of parallel block writes per PUT request /// Higher values improve throughput but increase memory usage /// Default: 3, Recommended: 10-30 for NVMe, 3-10 for HDD - #[serde(default = "default_put_blocks_max_parallel")] - pub put_blocks_max_parallel: usize, + #[serde(default = "default_block_max_concurrent_writes_per_request")] + pub block_max_concurrent_writes_per_request: usize, /// Number of replicas. Can be any positive integer, but uneven numbers are more favorable. /// - 1 for single-node clusters, or to disable replication /// - 3 is the recommended and supported setting. @@ -272,7 +272,7 @@ pub struct KubernetesDiscoveryConfig { pub skip_crd: bool, } -pub fn default_put_blocks_max_parallel() -> usize { +pub fn default_block_max_concurrent_writes_per_request() -> usize { 3 } /// Read and parse configuration From dcc2fe4ac549e07bbefa1879743e7bd42296dcc5 Mon Sep 17 00:00:00 2001 From: Simon Pasquier Date: Tue, 16 Dec 2025 10:16:44 +0100 Subject: [PATCH 182/192] docs: fix typo in doc/book/cookbook/kubernetes.md --- doc/book/cookbook/kubernetes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/book/cookbook/kubernetes.md b/doc/book/cookbook/kubernetes.md index 1e7674d7..f5bceec8 100644 --- a/doc/book/cookbook/kubernetes.md +++ b/doc/book/cookbook/kubernetes.md @@ -11,7 +11,7 @@ Firstly clone the repository: ```bash git clone https://git.deuxfleurs.fr/Deuxfleurs/garage -cd garage/scripts/helm +cd garage/script/helm ``` Deploy with default options: From bf5290036f16563818293d3367ac84a024f46587 Mon Sep 17 00:00:00 2001 From: Pierre Mavro Date: Thu, 18 Dec 2025 18:12:22 +0100 Subject: [PATCH 183/192] feat: add service annotations --- script/helm/garage/templates/service.yaml | 6 +++++- script/helm/garage/values.yaml | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/script/helm/garage/templates/service.yaml b/script/helm/garage/templates/service.yaml index 37218872..887c90d0 100644 --- a/script/helm/garage/templates/service.yaml +++ b/script/helm/garage/templates/service.yaml @@ -4,6 +4,10 @@ metadata: name: {{ include "garage.fullname" . }} labels: {{- include "garage.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: type: {{ .Values.service.type }} ports: @@ -37,4 +41,4 @@ spec: name: metrics selector: {{- include "garage.selectorLabels" . | nindent 4 }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/script/helm/garage/values.yaml b/script/helm/garage/values.yaml index bbb60db2..5e419fe2 100644 --- a/script/helm/garage/values.yaml +++ b/script/helm/garage/values.yaml @@ -124,6 +124,8 @@ service: # - NodePort (+ Ingress) # - LoadBalancer type: ClusterIP + # -- Annotations to add to the service + annotations: {} s3: api: port: 3900 From 424d4f8d4d6cfd2dff5ab027b82fda9a233e2b31 Mon Sep 17 00:00:00 2001 From: nmstoker Date: Sat, 20 Dec 2025 13:16:38 +0000 Subject: [PATCH 184/192] Update doc/book/cookbook/binary-packages.md Correct the Arch Linux link as garage is now available in the official repos under extra, and no longer in AUR. --- doc/book/cookbook/binary-packages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/book/cookbook/binary-packages.md b/doc/book/cookbook/binary-packages.md index 6476ff51..ce6beb7b 100644 --- a/doc/book/cookbook/binary-packages.md +++ b/doc/book/cookbook/binary-packages.md @@ -27,7 +27,7 @@ it's stable). ## Arch Linux -Garage is available in the [AUR](https://aur.archlinux.org/packages/garage). +Garage is available in the official repositories under [extra](https://archlinux.org/packages/extra/x86_64/garage). ## FreeBSD From 8eb12755e4ba1527dfc68ac63a8101503e306a8f Mon Sep 17 00:00:00 2001 From: Joe Anderson Date: Sun, 18 Jan 2026 15:04:56 +0000 Subject: [PATCH 185/192] Allow `bucket` to be missing from presigned post params --- src/api/s3/post_object.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/api/s3/post_object.rs b/src/api/s3/post_object.rs index b9bccae6..09be7e7c 100644 --- a/src/api/s3/post_object.rs +++ b/src/api/s3/post_object.rs @@ -141,10 +141,26 @@ pub async fn handle_post_object( let mut conditions = decoded_policy.into_conditions()?; + // If there are conditions on the bucket name, check these against the actual bucket_name rather + // than the one in params, which is allowed to be absent. + if let Some(conds) = conditions.params.remove("bucket") { + for cond in conds { + let ok = match cond { + Operation::Equal(s) => s.as_str() == bucket_name, + Operation::StartsWith(s) => bucket_name.starts_with(&s), + }; + if !ok { + return Err(Error::bad_request( + "Key 'bucket' has value not allowed in policy", + )); + } + } + } + for (param_key, value) in params.iter() { let param_key = param_key.as_str(); match param_key { - "policy" | "x-amz-signature" => (), // this is always accepted, as it's required to validate other fields + "policy" | "x-amz-signature" | "bucket" => (), // this is always accepted, as it's required to validate other fields "content-type" => { let conds = conditions.params.remove("content-type").ok_or_else(|| { Error::bad_request(format!("Key '{}' is not allowed in policy", param_key)) From a7d6620e18d64e6b2b7a477e546f3c737f8bb6f5 Mon Sep 17 00:00:00 2001 From: rmoff Date: Mon, 12 Jan 2026 17:32:02 +0000 Subject: [PATCH 186/192] Fix typo in error message --- src/rpc/layout/version.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rpc/layout/version.rs b/src/rpc/layout/version.rs index b7902898..a02fce89 100644 --- a/src/rpc/layout/version.rs +++ b/src/rpc/layout/version.rs @@ -507,7 +507,7 @@ impl LayoutVersion { g.compute_maximal_flow()?; if g.get_flow_value()? < (NB_PARTITIONS * self.replication_factor) as i64 { return Err(Error::Message( - "The storage capacity of he cluster is to small. It is \ + "The storage capacity of the cluster is too small. It is \ impossible to store partitions of size 1." .into(), )); From 5df37dae5e681f51fa2c9ac81bce103ab2e00b32 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 24 Jan 2026 11:59:01 +0000 Subject: [PATCH 187/192] update cargo dependencies in main-v1 (#1299) Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1299 Co-authored-by: Alex Auvolat Co-committed-by: Alex Auvolat --- Cargo.lock | 1666 ++++++++++++++++++++++++++++------------------------ 1 file changed, 911 insertions(+), 755 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69bad7a9..5fc4ae5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -54,22 +54,22 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.2.15", + "getrandom 0.3.4", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -80,12 +80,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -97,9 +91,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -112,50 +106,53 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.97" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "argon2" @@ -187,16 +184,14 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.21" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cf008e5e1a9e9e22a7d3c9a4992e21a350290069e36d8fb72304ed17e8f2d2" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" dependencies = [ - "futures-core", - "memchr", + "compression-codecs", + "compression-core", "pin-project-lite", "tokio", - "zstd", - "zstd-safe", ] [[package]] @@ -218,18 +213,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -240,15 +235,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-credential-types" -version = "1.2.2" +version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4471bef4c22a06d2c7a1b6492493d3fdf24a805323109d6874f9c94d5906ac14" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -258,9 +253,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.6" +version = "1.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aff45ffe35196e593ea3b9dd65b320e51e2dda95aff4390bc459e461d09c6ad" +checksum = "959dab27ce613e6c9658eb3621064d0e2027e5f2acb65bc526a43577facea557" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -275,7 +270,6 @@ dependencies = [ "fastrand", "http 0.2.12", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "tracing", @@ -284,31 +278,32 @@ dependencies = [ [[package]] name = "aws-sdk-config" -version = "1.65.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2645fb2c8b9876a46a3d79f06aad47063baf054085ea887a1e6d6f159e8a7501" +checksum = "67e62e5ffb669e13f084c4e1d89d687604e001187f61503606a7f8cc7a411995" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", "aws-types", "bytes", + "fastrand", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-s3" -version = "1.79.0" +version = "1.120.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f63ba8f5fca32061c7d62d866ef65470edde38d4c5f8a0ebb8ff40a0521e1c" +checksum = "06673901e961f20fa8d7da907da48f7ad6c1b383e3726c22bd418900f015abe1" dependencies = [ "aws-credential-types", "aws-runtime", @@ -318,6 +313,7 @@ dependencies = [ "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -328,10 +324,9 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.3.1", + "http 1.4.0", "http-body 0.4.6", "lru", - "once_cell", "percent-encoding", "regex-lite", "sha2", @@ -341,9 +336,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.3.0" +version = "1.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d03c3c05ff80d54ff860fe38c726f6f494c639ae975203a101335f223386db" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -355,8 +350,7 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.3.1", - "once_cell", + "http 1.4.0", "percent-encoding", "sha2", "time", @@ -365,9 +359,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.5" +version = "1.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e190749ea56f8c42bf15dd76c65e14f8f765233e6df9b0506d9d934ebef867c" +checksum = "9ee19095c7c4dda59f1697d028ce704c24b2d33c6718790c7f1d5a3015b4107c" dependencies = [ "futures-util", "pin-project-lite", @@ -376,16 +370,14 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.63.1" +version = "0.63.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d21e1ba6f2cdec92044f904356a19f5ad86961acf015741106cdfafd747c0" +checksum = "23374b9170cbbcc6f5df8dc5ebb9b6c5c28a3c8f599f0e8b8b10eb6f4a5c6e74" dependencies = [ "aws-smithy-http", "aws-smithy-types", "bytes", - "crc32c", - "crc32fast", - "crc64fast-nvme", + "crc-fast", "hex", "http 0.2.12", "http-body 0.4.6", @@ -398,9 +390,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.8" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c45d3dddac16c5c59d553ece225a88870cf81b7b813c9cc17b78cf4685eac7a" +checksum = "dc12f8b310e38cad85cf3bef45ad236f470717393c613266ce0a89512286b650" dependencies = [ "aws-smithy-types", "bytes", @@ -409,9 +401,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.0" +version = "0.62.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5949124d11e538ca21142d1fba61ab0a2a2c1bc3ed323cdb3e4b878bfb83166" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -419,10 +411,10 @@ dependencies = [ "bytes", "bytes-utils", "futures-core", + "futures-util", "http 0.2.12", - "http 1.3.1", + "http 1.4.0", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "pin-utils", @@ -431,51 +423,62 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.0.0" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0497ef5d53065b7cd6a35e9c1654bd1fefeae5c52900d91d1b188b0af0f29324" +checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.4.8", + "h2 0.3.27", + "h2 0.4.13", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", "hyper-rustls 0.24.2", "pin-project-lite", "rustls 0.21.12", + "rustls-native-certs 0.8.3", "tokio", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.61.3" +version = "0.61.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92144e45819cae7dc62af23eac5a038a58aa544432d2102609654376a900bd07" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" dependencies = [ "aws-smithy-types", ] [[package]] -name = "aws-smithy-runtime" -version = "1.8.0" +name = "aws-smithy-observability" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6328865e36c6fd970094ead6b05efd047d3a80ec5fc3be5e743910da9f2ebf8" +checksum = "ef1fcbefc7ece1d70dcce29e490f269695dfca2d2bacdeaf9e5c3f799e4e6a42" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5b6167fcdf47399024e81ac08e795180c576a20e4d4ce67949f9a88ae37dc1" dependencies = [ "aws-smithy-async", "aws-smithy-http", "aws-smithy-http-client", + "aws-smithy-observability", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", "fastrand", "http 0.2.12", - "http 1.3.1", + "http 1.4.0", "http-body 0.4.6", "http-body 1.0.1", - "once_cell", "pin-project-lite", "pin-utils", "tokio", @@ -484,15 +487,15 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.7.4" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da37cf5d57011cb1753456518ec76e31691f1f474b73934a284eb2a1c76510f" +checksum = "efce7aaaf59ad53c5412f14fc19b2d5c6ab2c3ec688d272fd31f76ec12f44fb0" dependencies = [ "aws-smithy-async", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.3.1", + "http 1.4.0", "pin-project-lite", "tokio", "tracing", @@ -501,16 +504,16 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.0" +version = "1.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836155caafba616c0ff9b07944324785de2ab016141c3550bd1c07882f8cee8f" +checksum = "65f172bcb02424eb94425db8aed1b6d583b5104d4d5ddddf22402c661a320048" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.3.1", + "http 1.4.0", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -522,23 +525,23 @@ dependencies = [ "serde", "time", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", ] [[package]] name = "aws-smithy-xml" -version = "0.60.9" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0b0166827aa700d3dc519f72f8b3a91c35d0b8d042dc5d643a91e6f80648fc" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.6" +version = "1.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3873f8deed8927ce8d04487630dc9ff73193bab64742a61d050e57a68dec4125" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -554,16 +557,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "instant", "rand", ] [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -571,7 +574,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -604,9 +607,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bincode" @@ -625,9 +628,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "blake2" @@ -649,15 +652,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder" @@ -667,9 +670,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "bytes-utils" @@ -683,9 +686,9 @@ dependencies = [ [[package]] name = "bytesize" -version = "1.3.2" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" [[package]] name = "byteview" @@ -695,10 +698,11 @@ checksum = "6236364b88b9b6d0bc181ba374cf1ab55ba3ef97a1cb6f8cddad48a273767fb5" [[package]] name = "cc" -version = "1.2.16" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -706,9 +710,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -724,11 +728,10 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -760,9 +763,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.32" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -770,9 +773,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.32" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -782,27 +785,27 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compare" @@ -810,6 +813,23 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "compression-core", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "core-foundation" version = "0.9.4" @@ -820,6 +840,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -837,9 +867,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -850,6 +880,18 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin 0.10.0", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -861,22 +903,13 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "crc64fast-nvme" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4955638f00a809894c947f85a024020a20815b65a5eea633798ea7924edab2b3" -dependencies = [ - "crc", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -922,9 +955,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "rand_core", @@ -942,9 +975,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -952,27 +985,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -996,14 +1029,14 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core 0.9.10", + "parking_lot_core", ] [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -1038,7 +1071,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -1049,9 +1082,9 @@ checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" @@ -1077,7 +1110,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -1101,12 +1134,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1127,6 +1160,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1163,10 +1202,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "form_urlencoded" -version = "1.2.1" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1231,7 +1276,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -1295,9 +1340,9 @@ dependencies = [ "git-version", "hex", "hmac", - "http 1.3.1", + "http 1.4.0", "http-body-util", - "hyper 1.6.0", + "hyper 1.8.1", "hyper-util", "k2v-client", "kuska-sodiumoxide", @@ -1333,14 +1378,14 @@ dependencies = [ "garage_table", "garage_util", "hex", - "http 1.3.1", - "hyper 1.6.0", + "http 1.4.0", + "hyper 1.8.1", "opentelemetry", "opentelemetry-prometheus", "prometheus", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -1362,9 +1407,9 @@ dependencies = [ "garage_util", "hex", "hmac", - "http 1.3.1", + "http 1.4.0", "http-body-util", - "hyper 1.6.0", + "hyper 1.8.1", "hyper-util", "md-5", "nom", @@ -1374,7 +1419,7 @@ dependencies = [ "serde_json", "sha1", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -1390,14 +1435,14 @@ dependencies = [ "garage_model", "garage_table", "garage_util", - "http 1.3.1", + "http 1.4.0", "http-body-util", - "hyper 1.6.0", + "hyper 1.8.1", "opentelemetry", "percent-encoding", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -1424,11 +1469,11 @@ dependencies = [ "garage_table", "garage_util", "hex", - "http 1.3.1", + "http 1.4.0", "http-body-util", "http-range", "httpdate", - "hyper 1.6.0", + "hyper 1.8.1", "md-5", "multer", "opentelemetry", @@ -1440,10 +1485,10 @@ dependencies = [ "serde_json", "sha1", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-stream", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tracing", "url", ] @@ -1468,7 +1513,7 @@ dependencies = [ "rand", "serde", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tracing", "zstd", ] @@ -1480,11 +1525,11 @@ dependencies = [ "fjall", "heed", "mktemp", - "parking_lot 0.12.3", + "parking_lot", "r2d2", "r2d2_sqlite", "rusqlite", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", ] @@ -1504,12 +1549,12 @@ dependencies = [ "garage_table", "garage_util", "hex", - "http 1.3.1", + "http 1.4.0", "parse_duration", "rand", "serde", "serde_bytes", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "zstd", @@ -1534,10 +1579,10 @@ dependencies = [ "rand", "rmp-serde", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-stream", - "tokio-util 0.7.14", + "tokio-util 0.7.18", ] [[package]] @@ -1567,7 +1612,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -1607,8 +1652,8 @@ dependencies = [ "garage_net", "hex", "hexdump", - "http 1.3.1", - "hyper 1.6.0", + "http 1.4.0", + "hyper 1.8.1", "lazy_static", "mktemp", "opentelemetry", @@ -1618,7 +1663,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "toml", "tracing", @@ -1634,12 +1679,12 @@ dependencies = [ "garage_model", "garage_table", "garage_util", - "http 1.3.1", + "http 1.4.0", "http-body-util", - "hyper 1.6.0", + "hyper 1.8.1", "opentelemetry", "percent-encoding", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -1666,25 +1711,25 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", ] [[package]] @@ -1699,9 +1744,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "git-version" @@ -1720,7 +1765,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -1731,9 +1776,9 @@ checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -1741,29 +1786,29 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.8.0", + "indexmap 2.13.0", "slab", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tracing", ] [[package]] name = "h2" -version = "0.4.8" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.8.0", + "http 1.4.0", + "indexmap 2.13.0", "slab", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tracing", ] @@ -1785,13 +1830,22 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1800,7 +1854,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.15.5", ] [[package]] @@ -1858,15 +1912,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1894,11 +1942,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1914,12 +1962,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1941,7 +1988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -1952,7 +1999,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -1983,9 +2030,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" @@ -1997,14 +2044,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2013,20 +2060,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "h2 0.4.8", - "http 1.3.1", + "futures-core", + "h2 0.4.13", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2055,8 +2104,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", - "http 1.3.1", - "hyper 1.6.0", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", "rustls 0.22.4", "rustls-native-certs 0.7.3", @@ -2080,33 +2129,42 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", + "system-configuration 0.6.1", "tokio", + "tower-layer", "tower-service", "tracing", + "windows-registry", ] [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -2122,21 +2180,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -2145,99 +2204,61 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "ident_case" version = "1.0.1" @@ -2246,9 +2267,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2257,9 +2278,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2277,12 +2298,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.8.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.16.1", ] [[package]] @@ -2329,20 +2350,20 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "hermit-abi 0.5.0", + "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -2364,24 +2385,25 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2418,12 +2440,12 @@ dependencies = [ "aws-sdk-config", "aws-sigv4", "base64 0.21.7", - "clap 4.5.32", + "clap 4.5.54", "format_table", "hex", - "http 1.3.1", + "http 1.4.0", "http-body-util", - "hyper 1.6.0", + "hyper 1.8.1", "hyper-rustls 0.26.0", "hyper-util", "log", @@ -2431,7 +2453,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing-subscriber", ] @@ -2492,7 +2514,7 @@ dependencies = [ "serde_yaml", "thiserror 1.0.69", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tower", "tower-http", "tracing", @@ -2526,7 +2548,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -2544,14 +2566,14 @@ dependencies = [ "json-patch", "k8s-openapi", "kube-client", - "parking_lot 0.12.3", + "parking_lot", "pin-project", "serde", "serde_json", "smallvec", "thiserror 1.0.69", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tracing", ] @@ -2587,9 +2609,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.171" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libsodium-sys" @@ -2622,15 +2644,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lmdb-rkv-sys" @@ -2645,34 +2667,33 @@ dependencies = [ [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.26" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.16.1", ] [[package]] name = "lsm-tree" -version = "2.10.3" +version = "2.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab73c02eadb3dc12c0024e5b61d6284e6d59064e67e74fbad77856caa56f62c7" +checksum = "799399117a2bfb37660e08be33f470958babb98386b04185288d829df362ea15" dependencies = [ "byteorder", "crossbeam-skiplist", @@ -2694,17 +2715,17 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2719,9 +2740,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mime" @@ -2737,22 +2758,22 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -2773,11 +2794,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.3.1", + "http 1.4.0", "httparse", "memchr", "mime", - "spin", + "spin 0.9.8", "version_check", ] @@ -2793,7 +2814,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -2817,12 +2838,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -2862,9 +2882,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2909,28 +2929,34 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.21.1" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opaque-debug" @@ -2944,6 +2970,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "opentelemetry" version = "0.17.0" @@ -3021,12 +3053,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "page_size" version = "0.4.2" @@ -3039,50 +3065,25 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.11.2" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core 0.9.10", + "parking_lot_core", ] [[package]] name = "parking_lot_core" -version = "0.8.6" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.10", + "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -3107,12 +3108,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "path-absolutize" version = "3.1.1" @@ -3133,36 +3128,35 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.7.15" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", - "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.15" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", @@ -3170,24 +3164,23 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.15" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "pest_meta" -version = "2.7.15" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ - "once_cell", "pest", "sha2", ] @@ -3199,7 +3192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.8.0", + "indexmap 2.13.0", ] [[package]] @@ -3219,7 +3212,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -3284,6 +3277,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3296,7 +3298,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.23", + "zerocopy", ] [[package]] @@ -3335,9 +3337,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3352,7 +3354,7 @@ dependencies = [ "fnv", "lazy_static", "memchr", - "parking_lot 0.12.3", + "parking_lot", "protobuf", "thiserror 1.0.69", ] @@ -3428,28 +3430,28 @@ dependencies = [ [[package]] name = "quick_cache" -version = "0.6.16" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ad6644cb07b7f3488b9f3d2fde3b4c0a7fa367cafefb39dff93a659f76eb786" +checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.16.1", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "r2d2" @@ -3458,7 +3460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ "log", - "parking_lot 0.12.3", + "parking_lot", "scheduled-thread-pool", ] @@ -3500,76 +3502,52 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", ] [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" -dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-lite" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" @@ -3582,7 +3560,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -3600,7 +3578,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-rustls 0.24.1", "tower-service", @@ -3619,7 +3597,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3627,22 +3605,19 @@ dependencies = [ [[package]] name = "rmp" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" dependencies = [ - "byteorder", "num-traits", - "paste", ] [[package]] name = "rmp-serde" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" dependencies = [ - "byteorder", "rmp", "serde", ] @@ -3659,7 +3634,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3669,9 +3644,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" @@ -3694,7 +3669,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3703,15 +3678,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.3" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.9.3", - "windows-sys 0.59.0", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -3745,10 +3720,10 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile 1.0.4", "schannel", - "security-framework", + "security-framework 2.11.1", ] [[package]] @@ -3757,11 +3732,23 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", ] [[package]] @@ -3784,9 +3771,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] [[package]] name = "rustls-webpki" @@ -3811,15 +3801,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -3832,11 +3822,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3845,7 +3835,7 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" dependencies = [ - "parking_lot 0.12.3", + "parking_lot", ] [[package]] @@ -3869,7 +3859,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -3904,8 +3894,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.0", - "core-foundation", + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3913,9 +3916,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -3923,22 +3926,23 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -3954,22 +3958,32 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -3980,26 +3994,27 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -4022,7 +4037,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.8.0", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -4042,9 +4057,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -4068,38 +4083,46 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -4107,22 +4130,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] -name = "stable_deref_trait" -version = "1.2.0" +name = "spin" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_init" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2a1c578e98c1c16fc3b8ec1328f7659a500737d7a0c6d625e73e830ff9c1f6" +checksum = "8bae1df58c5fea7502e8e352ec26b5579f6178e1fdb311e088580c980dee25ed" dependencies = [ "bitflags 1.3.2", - "cfg_aliases 0.1.1", + "cfg_aliases 0.2.1", "libc", - "parking_lot 0.11.2", - "parking_lot_core 0.8.6", + "parking_lot", + "parking_lot_core", "static_init_macro", "winapi", ] @@ -4195,9 +4224,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.100" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -4221,13 +4250,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -4248,8 +4277,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -4263,16 +4303,26 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.19.1" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.3.4", "once_cell", - "rustix 1.0.3", - "windows-sys 0.59.0", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -4304,11 +4354,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.18", ] [[package]] @@ -4319,55 +4369,54 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.40" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d9c75b47bdff86fa3334a3db91356b8d7d86a9b839dab7d0bdc5c3d3a077618" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.21" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29aa485584182073ed57fd5004aa09c371f021325014694e432313345865fd04" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -4381,9 +4430,9 @@ checksum = "a1710e589de0a76aaf295cd47a6699f6405737dbfd3cf2b75c92d000b548d0e6" [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -4391,27 +4440,26 @@ dependencies = [ [[package]] name = "tokio" -version = "1.44.1" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", "libc", "mio", - "parking_lot 0.12.3", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-io-timeout" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" dependencies = [ "pin-project-lite", "tokio", @@ -4419,13 +4467,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -4451,9 +4499,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -4476,9 +4524,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -4491,9 +4539,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.20" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", @@ -4503,20 +4551,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.24" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.8.0", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime", @@ -4535,7 +4583,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -4580,7 +4628,7 @@ dependencies = [ "rand", "slab", "tokio", - "tokio-util 0.7.14", + "tokio-util 0.7.18", "tower-layer", "tower-service", "tracing", @@ -4593,7 +4641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ "base64 0.21.7", - "bitflags 2.9.0", + "bitflags 2.10.0", "bytes", "futures-core", "futures-util", @@ -4621,9 +4669,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4633,20 +4681,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -4664,9 +4712,9 @@ dependencies = [ [[package]] name = "tracing-journald" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0b4143302cf1022dac868d521e36e8b27691f72c84b3311750d5188ebba657" +checksum = "2d3a81ed245bfb62592b1e2bc153e77656d94ee6a0497683a65a12ccaf2438d0" dependencies = [ "libc", "tracing-core", @@ -4686,14 +4734,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -4710,9 +4758,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -4722,9 +4770,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" @@ -4762,21 +4810,16 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4795,7 +4838,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "rand", ] @@ -4867,52 +4910,40 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.100", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4921,9 +4952,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4931,31 +4962,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.100", - "wasm-bindgen-backend", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4991,11 +5022,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5006,18 +5037,73 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] name = "windows-link" -version = "0.1.1" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] [[package]] name = "windows-sys" @@ -5046,6 +5132,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5070,13 +5174,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5089,6 +5210,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5101,6 +5228,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5113,12 +5246,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5131,6 +5276,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5143,6 +5294,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5155,6 +5312,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5168,10 +5331,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.7.4" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -5187,25 +5356,16 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.0", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xmlparser" @@ -5221,11 +5381,10 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -5233,54 +5392,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" -dependencies = [ - "zerocopy-derive 0.8.23", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -5300,21 +5439,32 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -5323,15 +5473,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + [[package]] name = "zstd" version = "0.13.3" @@ -5343,18 +5499,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.3" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.14+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", From 47bf5d9fb0144be500d6328a8760364f0dc8d8d2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 24 Jan 2026 13:01:27 +0100 Subject: [PATCH 188/192] bump version to v1.3.1 --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 24 ++++++++++++------------ script/helm/garage/Chart.yaml | 4 ++-- script/helm/garage/README.md | 2 +- src/api/admin/Cargo.toml | 2 +- src/api/common/Cargo.toml | 2 +- src/api/k2v/Cargo.toml | 2 +- src/api/s3/Cargo.toml | 2 +- src/block/Cargo.toml | 2 +- src/db/Cargo.toml | 2 +- src/garage/Cargo.toml | 2 +- src/model/Cargo.toml | 2 +- src/net/Cargo.toml | 2 +- src/rpc/Cargo.toml | 2 +- src/table/Cargo.toml | 2 +- src/util/Cargo.toml | 2 +- src/web/Cargo.toml | 2 +- 17 files changed, 41 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fc4ae5a..7473d9af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,7 +1311,7 @@ dependencies = [ [[package]] name = "garage" -version = "1.3.0" +version = "1.3.1" dependencies = [ "assert-json-diff", "async-trait", @@ -1367,7 +1367,7 @@ dependencies = [ [[package]] name = "garage_api_admin" -version = "1.3.0" +version = "1.3.1" dependencies = [ "argon2", "async-trait", @@ -1393,7 +1393,7 @@ dependencies = [ [[package]] name = "garage_api_common" -version = "1.3.0" +version = "1.3.1" dependencies = [ "base64 0.21.7", "bytes", @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "garage_api_k2v" -version = "1.3.0" +version = "1.3.1" dependencies = [ "base64 0.21.7", "futures", @@ -1450,7 +1450,7 @@ dependencies = [ [[package]] name = "garage_api_s3" -version = "1.3.0" +version = "1.3.1" dependencies = [ "aes-gcm", "async-compression", @@ -1495,7 +1495,7 @@ dependencies = [ [[package]] name = "garage_block" -version = "1.3.0" +version = "1.3.1" dependencies = [ "arc-swap", "async-compression", @@ -1520,7 +1520,7 @@ dependencies = [ [[package]] name = "garage_db" -version = "1.3.0" +version = "1.3.1" dependencies = [ "fjall", "heed", @@ -1535,7 +1535,7 @@ dependencies = [ [[package]] name = "garage_model" -version = "1.3.0" +version = "1.3.1" dependencies = [ "async-trait", "base64 0.21.7", @@ -1562,7 +1562,7 @@ dependencies = [ [[package]] name = "garage_net" -version = "1.3.0" +version = "1.3.1" dependencies = [ "arc-swap", "bytes", @@ -1587,7 +1587,7 @@ dependencies = [ [[package]] name = "garage_rpc" -version = "1.3.0" +version = "1.3.1" dependencies = [ "arc-swap", "async-trait", @@ -1619,7 +1619,7 @@ dependencies = [ [[package]] name = "garage_table" -version = "1.3.0" +version = "1.3.1" dependencies = [ "arc-swap", "async-trait", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "garage_util" -version = "1.3.0" +version = "1.3.1" dependencies = [ "arc-swap", "async-trait", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "garage_web" -version = "1.3.0" +version = "1.3.1" dependencies = [ "garage_api_common", "garage_api_s3", diff --git a/Cargo.toml b/Cargo.toml index a21ac072..c293e004 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,18 +24,18 @@ default-members = ["src/garage"] # Internal Garage crates format_table = { version = "0.1.1", path = "src/format-table" } -garage_api_common = { version = "1.3.0", path = "src/api/common" } -garage_api_admin = { version = "1.3.0", path = "src/api/admin" } -garage_api_s3 = { version = "1.3.0", path = "src/api/s3" } -garage_api_k2v = { version = "1.3.0", path = "src/api/k2v" } -garage_block = { version = "1.3.0", path = "src/block" } -garage_db = { version = "1.3.0", path = "src/db", default-features = false } -garage_model = { version = "1.3.0", path = "src/model", default-features = false } -garage_net = { version = "1.3.0", path = "src/net" } -garage_rpc = { version = "1.3.0", path = "src/rpc" } -garage_table = { version = "1.3.0", path = "src/table" } -garage_util = { version = "1.3.0", path = "src/util" } -garage_web = { version = "1.3.0", path = "src/web" } +garage_api_common = { version = "1.3.1", path = "src/api/common" } +garage_api_admin = { version = "1.3.1", path = "src/api/admin" } +garage_api_s3 = { version = "1.3.1", path = "src/api/s3" } +garage_api_k2v = { version = "1.3.1", path = "src/api/k2v" } +garage_block = { version = "1.3.1", path = "src/block" } +garage_db = { version = "1.3.1", path = "src/db", default-features = false } +garage_model = { version = "1.3.1", path = "src/model", default-features = false } +garage_net = { version = "1.3.1", path = "src/net" } +garage_rpc = { version = "1.3.1", path = "src/rpc" } +garage_table = { version = "1.3.1", path = "src/table" } +garage_util = { version = "1.3.1", path = "src/util" } +garage_web = { version = "1.3.1", path = "src/web" } k2v-client = { version = "0.0.4", path = "src/k2v-client" } # External crates from crates.io diff --git a/script/helm/garage/Chart.yaml b/script/helm/garage/Chart.yaml index 51f98bbb..b3a7b921 100644 --- a/script/helm/garage/Chart.yaml +++ b/script/helm/garage/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: garage description: S3-compatible object store for small self-hosted geo-distributed deployments type: application -version: 0.7.2 -appVersion: "v1.3.0" +version: 0.7.3 +appVersion: "v1.3.1" home: https://garagehq.deuxfleurs.fr/ icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg diff --git a/script/helm/garage/README.md b/script/helm/garage/README.md index 25e548ec..bdf69ec4 100644 --- a/script/helm/garage/README.md +++ b/script/helm/garage/README.md @@ -1,6 +1,6 @@ # garage -![Version: 0.7.2](https://img.shields.io/badge/Version-0.7.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.3.0](https://img.shields.io/badge/AppVersion-v1.3.0-informational?style=flat-square) +![Version: 0.7.3](https://img.shields.io/badge/Version-0.7.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.3.1](https://img.shields.io/badge/AppVersion-v1.3.1-informational?style=flat-square) S3-compatible object store for small self-hosted geo-distributed deployments diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index 81735a85..656c6825 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_admin" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index b337cd69..df01d59a 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_common" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/k2v/Cargo.toml b/src/api/k2v/Cargo.toml index 2b77f676..28f74ea3 100644 --- a/src/api/k2v/Cargo.toml +++ b/src/api/k2v/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_k2v" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 56f90864..88630866 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_api_s3" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/block/Cargo.toml b/src/block/Cargo.toml index effa8dba..c4dbba44 100644 --- a/src/block/Cargo.toml +++ b/src/block/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_block" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index 7c1c8d90..9e860e7d 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_db" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index ad2b917b..a4f695a4 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 579092d2..289c0024 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_model" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/net/Cargo.toml b/src/net/Cargo.toml index 8ff78680..71f42c68 100644 --- a/src/net/Cargo.toml +++ b/src/net/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_net" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/rpc/Cargo.toml b/src/rpc/Cargo.toml index 9e886748..e23f4bca 100644 --- a/src/rpc/Cargo.toml +++ b/src/rpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_rpc" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/table/Cargo.toml b/src/table/Cargo.toml index 91ab110c..478dbd18 100644 --- a/src/table/Cargo.toml +++ b/src/table/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_table" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/util/Cargo.toml b/src/util/Cargo.toml index b5c1454f..46fa6590 100644 --- a/src/util/Cargo.toml +++ b/src/util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_util" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat "] edition = "2018" license = "AGPL-3.0" diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index a2daf84d..e0cb317f 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garage_web" -version = "1.3.0" +version = "1.3.1" authors = ["Alex Auvolat ", "Quentin Dufour "] edition = "2018" license = "AGPL-3.0" From 8551aefed478995c9e2de62ef9ff00eea921e1bf Mon Sep 17 00:00:00 2001 From: Armael Date: Sat, 7 Feb 2026 13:11:20 +0000 Subject: [PATCH 189/192] Fix: correctly parse CORS website configuration with no rules (#1320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sending a website config with an empty list of CORS rules, garage currently incorrectly refuses it with error message "Invalid XML: missing field `CORSRule`". This fix the issue by following the documentation of quick-xml related to serde field parameters for this specific scenario: https://docs.rs/quick-xml/latest/quick_xml/de/#sequences-xsall-and-xssequence-xml-schema-types . (I've based this PR on main-v1 because we want it for deuxfleurs' deployment.) Co-authored-by: Armaël Guéneau Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1320 Co-authored-by: Armael Co-committed-by: Armael --- src/api/s3/cors.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/api/s3/cors.rs b/src/api/s3/cors.rs index fcfdb934..1f365beb 100644 --- a/src/api/s3/cors.rs +++ b/src/api/s3/cors.rs @@ -88,7 +88,9 @@ pub async fn handle_put_cors( pub struct CorsConfiguration { #[serde(serialize_with = "xmlns_tag", skip_deserializing)] pub xmlns: (), - #[serde(rename = "CORSRule")] + // "default" is required to be able to parse an empty list of rules, + // cf https://docs.rs/quick-xml/latest/quick_xml/de/#sequences-xsall-and-xssequence-xml-schema-types + #[serde(rename = "CORSRule", default)] pub cors_rules: Vec, } @@ -270,4 +272,26 @@ mod tests { Ok(()) } + + #[test] + fn test_deserialize_norules() -> Result<(), Error> { + let message = r#" +"#; + let conf: CorsConfiguration = from_str(message).unwrap(); + let ref_value = CorsConfiguration { + xmlns: (), + cors_rules: vec![], + }; + assert_eq! { + ref_value, + conf + }; + + let message2 = to_xml_with_header(&ref_value)?; + + let cleanup = |c: &str| c.replace(char::is_whitespace, ""); + assert_eq!(cleanup(message), cleanup(&message2)); + + Ok(()) + } } From b72b090a097c8ee2711c8fb065d250ed68dcd0bf Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 21 Feb 2026 07:21:24 +0000 Subject: [PATCH 190/192] fix silent write errors (#1358) fix #1355 some write errors are not reported when calling write_all. That's notably the case of ENOSPC on small buffers (1MiB). on ext4, the error is catched when calling flush(). This is hopefully the case on most local filesystems, though afaik this assumption doesn't hold for NFS Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1358 Co-authored-by: trinity-1686a Co-committed-by: trinity-1686a --- src/block/manager.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/block/manager.rs b/src/block/manager.rs index 06cf9cbe..96ca9c90 100644 --- a/src/block/manager.rs +++ b/src/block/manager.rs @@ -783,6 +783,7 @@ impl BlockManagerLocked { let mut f = fs::File::create(&path_tmp).await?; f.write_all(data).await?; + f.flush().await?; mgr.metrics.bytes_written.add(data.len() as u64); if mgr.data_fsync { From 9987166b2b67767b534575a8c68396c8d6cf77f8 Mon Sep 17 00:00:00 2001 From: Gauthier Zirnhelt Date: Wed, 15 Apr 2026 09:56:24 +0000 Subject: [PATCH 191/192] Fix the LifecycleWorker being uncooperative (#1396) ## Summary This PR ensures that the `LifecycleWorker` yields at least once to the Tokio scheduler in between each batch of 100 objects. ## Problem being solved I'm administrating a Garage cluster which has been experiencing timeouts on all endpoints while the lifecycle worker is running at midnight UTC : `Ping timeout` error messages and even requests eventually failing due to `Could not reach quorum ...`. I have found that this happens while the lifecycle worker is working on a big bucket (containing millions of objects) with a lifecycle rule that applies to very few objects. The `process_object()` function does not hit any `await`: - `last_bucket` is always the same, so the `bucket_table` is not read asynchronously - no transaction is made on the `object_table` because my lifecycle rule (almost) never applies to any object The first commit in this PR adds an executable which reproduces the problem that I've been experiencing in a self-contained way : the lifecycle worker starves the Tokio scheduler so much that no other task is able to run (or very rarely). To run it : `cargo run -p garage_model --bin lifecycle-starvation-test`. This commit can be dropped post-review, as it's only useful to demonstrate the starvation. The error messages completely stopped after adding the extra yield to the nodes of my cluster. The duration of the lifecycle worker task does not appear to have changed at all from what I can see (looking at the timestamps produced either by the self-contained binary or by each of my nodes with the `Lifecycle worker finished` message). ## Note An other potential fix would have been to force the `WorkerProcessor` to yield before re-enqueuing a busy task, but this would have affected all Garage workers even though it's only the `LifecycleWorker` being uncooperative. Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1396 Reviewed-by: Alex Co-authored-by: Gauthier Zirnhelt Co-committed-by: Gauthier Zirnhelt --- src/util/background/worker.rs | 45 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/util/background/worker.rs b/src/util/background/worker.rs index 76fb14e8..3c938b7e 100644 --- a/src/util/background/worker.rs +++ b/src/util/background/worker.rs @@ -115,32 +115,39 @@ impl WorkerProcessor { trace!("{} (TID {}): {:?}", worker.worker.name(), worker.task_id, worker.state); // Save worker info - let mut wi = self.worker_info.lock().unwrap(); - match wi.get_mut(&worker.task_id) { - Some(i) => { - i.state = worker.state; - i.status = worker.worker.status(); - i.errors = worker.errors; - i.consecutive_errors = worker.consecutive_errors; - if worker.last_error.is_some() { - i.last_error = worker.last_error.take(); + { + let mut wi = self.worker_info.lock().unwrap(); + match wi.get_mut(&worker.task_id) { + Some(i) => { + i.state = worker.state; + i.status = worker.worker.status(); + i.errors = worker.errors; + i.consecutive_errors = worker.consecutive_errors; + if worker.last_error.is_some() { + i.last_error = worker.last_error.take(); + } + } + None => { + wi.insert(worker.task_id, WorkerInfo { + name: worker.worker.name(), + state: worker.state, + status: worker.worker.status(), + errors: worker.errors, + consecutive_errors: worker.consecutive_errors, + last_error: worker.last_error.take(), + }); } - } - None => { - wi.insert(worker.task_id, WorkerInfo { - name: worker.worker.name(), - state: worker.state, - status: worker.worker.status(), - errors: worker.errors, - consecutive_errors: worker.consecutive_errors, - last_error: worker.last_error.take(), - }); } } if worker.state == WorkerState::Done { info!("Worker {} (TID {}) exited", worker.worker.name(), worker.task_id); } else { + // Yield to the Tokio scheduler between consecutive Busy steps so + // that a worker which never suspends on its own cannot starve other tasks. + if worker.state == WorkerState::Busy { + tokio::task::yield_now().await; + } workers.push(async move { worker.step().await; worker From b6b18427a5fc9b5b7540cf6d0843afd5cf7f9b56 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 16 Apr 2026 08:47:02 +0000 Subject: [PATCH 192/192] use optimization level 3 and thin LTO for release builds (#1405) Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1405 Co-authored-by: Alex Auvolat Co-committed-by: Alex Auvolat --- Cargo.toml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c293e004..df4005a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,12 +146,8 @@ aws-smithy-runtime = { version = "1.8", default-features = false, features = ["t aws-sdk-config = { version = "1.62", default-features = false } aws-sdk-s3 = { version = "1.79", default-features = false, features = ["rt-tokio"] } -[profile.dev] -#lto = "thin" # disabled for now, adds 2-4 min to each CI build -lto = "off" - [profile.release] -lto = true -codegen-units = 1 -opt-level = "s" -strip = true +lto = "thin" +codegen-units = 16 +opt-level = 3 +strip = "debuginfo"